feat(board): session attention, Discord rows, task attribution and relaunch activity (rows 18, 22, #1511, #1512)

One cumulative control-board, webui and seat state. The four rows edit the
same files (scan.mjs, page.html, README.md, app.js), so they land together,
each on its own receipt:

- Row 18, Discord connector rows on the board (#1509): R3 approved by
  Darkwing and Dewey, Gitea comment 26257, manifest 254403b8. Jason
  accepted the visual test.
- Row 22, board attention status (#1503): Filbert approved R1, comment
  26248, manifest e40b58ec; restart receipt 26249.
- #1511, task attribution (row 6 code phase): R2 approved by Filbert and
  Dewey, manifest d4c96395. docs/TOOLS.md carries the approved --by usage
  line (tools-usage.patch 86bcba3c).
- #1512, relaunch activity (row 6 pilot): R1 approved by Darkwing and
  Dewey, candidate manifest 47769fad. All seven source files match it.

Row 16, internal development bootstrap (#1510): the seven files outside
shared records match Filbert's R1 pins, receipt 26204 (agents/researcher/*,
scripts/test-darkwing-launch.mjs, the bootstrap plan).

packages/webui/src/public/app.js is committed at its #1512 R1 pin ce7d79a4.
The working copy holds Dewey's unreviewed return-flow candidate on top of
that, and it stays uncommitted.

Also: the four row briefs and Darkwing's evidence records under
agents/darkwing/work, including the 2026-09-26 tree manifest and the #1512
re-run against 21e3e908. Serial acceptance command: 397/397, three runs.
The failures that only show when tests run concurrently are in #1509 engine
tests, and they reproduce on clean HEAD.

Suites on the exact staged tree: config 24, task 90, foundation 43,
conductor 17, release 14, auth 15, discord 63; package union 397/397
(serial); test-darkwing-launch 5/5.

Shared records (BUILD-LOG, QUEUE, CURRENT, DEFERRED, SESSIONS, AGENTS.md,
agents/README.md) follow in Sage's records commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 14:54:18 -05:00
co-authored by Claude Opus 5.5
parent 21e3e908b6
commit af4203ca92
67 changed files with 2417 additions and 78 deletions
@@ -0,0 +1,17 @@
{
"issue": 1503,
"candidate": "/tmp/board-attention-r1-q_924ksh",
"files": [
"AGENTS.md",
"packages/control-board/src/scan.mjs",
"packages/control-board/README.md",
"packages/control-board/tests/scan.test.mjs",
"packages/control-board/tests/serve.test.mjs",
"packages/control-board/tests/attention.test.mjs",
"packages/control-board/tests/attention-flow.test.mjs",
"packages/webui/tests/fixture.mjs",
"docs/plans/2026-09-13_board-attention-status.md"
],
"manifestSha256": "e40b58ecb6844d407ba776dcde8f19ad21c0b78076ca1f9b3d96b0bb1c852405",
"testsPassed": 144
}
@@ -0,0 +1,14 @@
{
"at": "2026-09-14T00:19:32.409806+00:00",
"backendPid": 3204655,
"health": "ok",
"researcher": {
"agent": "researcher",
"project": "mosaic-stack",
"alive": true,
"state": "idle",
"waitingOnYou": false,
"lastActivity": "2026-09-13T21:19:57.102Z"
},
"fiveAgentProcessesUnchanged": true
}
@@ -0,0 +1,46 @@
{
"at": "2026-09-14T00:19:11.579013+00:00",
"ownerAuthorized": true,
"oldPid": 1265952,
"newPid": 3204655,
"command": [
"/usr/bin/node",
"packages/control-board/src/cli.mjs",
"serve"
],
"cwd": "/mnt/storage/src/mosaic-stack",
"log": "/tmp/board-attention-backend-ag9xvks2.log",
"agentProcessesBefore": {
"default/darkwing": [
[
"2733924",
"12863634"
]
],
"default/dewey": [
[
"934346",
"466065"
]
],
"default/filbert": [
[
"72183",
"100870"
]
],
"default/researcher": [
[
"173699",
"66404285"
]
],
"mosaic-fleet/rocko": [
[
"90599",
"128275"
]
]
},
"gracefulExitObserved": true
}
@@ -0,0 +1,28 @@
# Independent acceptance checklist, row 18
Darkwing reviews Filbert's implementation without editing its source candidate.
Dewey reviews visible connector presentation. No live connector manipulation.
- Discovery accepts only safe matching binding name/seat from private regular
files, never dereferences a token path and never serializes private fields.
- Path traversal, symlinked binding/runtime/session paths and malformed records
cannot cause arbitrary reads or an actionable/live row.
- No owner, malformed owner, dead PID, missing identity, reused PID and boot
mismatch are non-live. A positively matching live process is live.
- STOP presence is visible as braked independently of process liveness. Its
contents are not read or exposed; no STOP or lock is created or changed.
- Ordinary completed messages remain idle. No false human attention regression.
- Connector rows cannot borrow a native agent's registration for replies.
Exercise replyToRow and HTTP using a fake executable hook; every connector
attempt must be refused before that hook runs, including with forged tmux
registration. Normal-agent reply tests must still pass.
- Both existing board and WebUI distinguish the connector and brake state and
omit reply controls. Preserve escaping, including hostile binding fixtures.
- Discovery errors disclose no private JSON fields or raw contents. One bad
binding must not silently manufacture a healthy row.
- Candidate pins match before and after tests. Existing dirty attention changes
remain intact; no unrelated source integration or live operation is inferred.
After source approval, measure the real row read-only. Offline/braked behavior
uses isolated fixtures unless the operator separately approves a live-service
transition. Board replacement is its own protected gate.
@@ -0,0 +1,23 @@
{
"at": "2026-09-14T13:51:10.530662+00:00",
"backendPid": 3769124,
"health": "ok",
"row": {
"agent": "sage (discord: shared-signals)",
"project": "fleet",
"state": "idle",
"alive": true,
"connector": {
"binding": "shared-signals",
"braked": false,
"ownerState": "live",
"alive": true
},
"task": "Discord connector",
"taskSource": "connector"
},
"replyStatus": 409,
"replyError": "board replies are disabled for Discord connectors",
"fiveAgentPaneIdentitiesUnchanged": true,
"connectorServiceIdentityUnchanged": true
}
@@ -0,0 +1,2 @@
{"at": "2026-09-14T13:50:24.078636+00:00", "event": "owner-authorized-restart-intent", "oldPid": 3204655, "agents": {"default/darkwing": [["2733924", "12863634"]], "default/dewey": [["934346", "466065"]], "default/filbert": [["72183", "100870"]], "default/researcher": [["173699", "66404285"]], "mosaic-fleet/rocko": [["90599", "128275"]]}, "connectorService": [3022843, "67887873"], "manifest": "254403b89c0a2330da53e8dbad1cbeba3b1b06cf4f3efddc18451e04cb78f6de"}
{"at": "2026-09-14T13:50:24.503231+00:00", "event": "replacement-started", "oldExitedGracefully": true, "newPid": 3769124, "log": "/tmp/discord-board-backend-ovk_cahk.log"}
@@ -0,0 +1,18 @@
{
"at": "2026-09-14T01:10:19.375709+00:00",
"candidate": "/tmp/discord-board-r1-KbMrGQWF",
"manifestSha256": "5c92acc90d202727d790f3fb8d74387db1c9e5c3e43d4f4b56b40e5ae503a56a",
"verdict": "CHANGES REQUIRED",
"independentSerializedTests": 320,
"finding": {
"id": "R1-B1",
"severity": "P2",
"file": "packages/control-board/src/discord.mjs",
"issue": "STOP metadata access errors collapse to absence, falsely projecting not braked",
"reproduction": "Synthetic journal directory contains STOP, chmod directory to 000 as uid 1000, inspectDiscord returns braked:false, ownerState:invalid, alive:false. Restore permissions and remove fixture.",
"expected": "braked:null/unknown when STOP existence cannot be established; false only for verified absence",
"required": "Distinguish missing metadata from access errors and add non-root unreadable-directory regression."
},
"ux": "Dewey APPROVE on exact R1; three independent serialized browser tests passed, source/automation limitations retained",
"parallelQualification": "Two author concurrent frozen timeouts remain unresolved and are not green; serialized independent run passed."
}
@@ -0,0 +1,7 @@
{
"candidate": "R2",
"syntheticOnly": true,
"taskContainsEnvelopeAuthorId": true,
"taskContainsEnvelopeMessageId": true,
"taskSource": "first-user-message"
}
@@ -0,0 +1,18 @@
{
"at": "2026-09-14T01:26:42.688410+00:00",
"candidate": "/tmp/discord-board-r3-U9vVrlQu",
"manifestSha256": "254403b89c0a2330da53e8dbad1cbeba3b1b06cf4f3efddc18451e04cb78f6de",
"reviewer": "Darkwing",
"backendVerdict": "APPROVE AS SOURCE",
"verified": "Nine working/frozen pins, exact three-file R2-to-R3 delta, inherited attention pins and full serialized six-package suite 322/322",
"findingsClosed": [
"R1-B1: inaccessible STOP is unknown, non-root regression passes",
"R2-B2: canonical routing envelope no longer becomes connector Task; ordinary fallback retained"
],
"limitations": [
"No generalized transcript redaction",
"R1 concurrent combined frozen timeouts unresolved/not green",
"No live observation, backend restart, connector change or publication in this review"
],
"uxGate": "Await exact R3 confirmation from Dewey via agent-send"
}
@@ -0,0 +1,20 @@
{
"at": "2026-09-14T01:28:17.695Z",
"sourceApproval": 26257,
"readOnly": true,
"agent": "sage (discord: shared-signals)",
"project": "fleet",
"state": "idle",
"alive": true,
"connector": {
"binding": "shared-signals",
"braked": false,
"ownerState": "live",
"alive": true
},
"task": "Discord connector",
"taskSource": "connector",
"registrationAbsent": true,
"ownerMatchesService": true,
"discoveryErrorCount": 0
}
@@ -0,0 +1,9 @@
{
"at": "2026-09-14T00:50:35.339Z",
"sourceSha256": "dfbb7ab9374c0ac9fafa0503f495abd938f499f5d6227233de03a60ea3022927",
"fixture": "connector row with forged native registration",
"status": 200,
"fakeTransportCalls": 1,
"realTransportCalls": 0,
"gatePassed": false
}
@@ -0,0 +1,25 @@
{
"observedAt": "2026-09-13T19:56:18.961314+00:00",
"issue": 1510,
"ownerAuthorizedLiveSmoke": true,
"researcher": [
{
"session": ".pi/state/researcher/sessions/2026-09-13T19-52-28-745Z_01a09c54-0b48-7154-addd-8fdce875aa4a.jsonl",
"entryId": "64b15fd5",
"timestamp": "2026-09-13T19:52:43.079Z",
"response": "RESEARCHER_NATIVE_SMOKE_OK",
"entrySha256": "d9cbaa5ea640d2b858a86b2fa24d3240b351d9383ffaee9721dd86fcd080c329"
}
],
"rocko": {
"newLaunch": "refused by existing native launch lock",
"existingPid": 3707667,
"cwd": "/mnt/storage/src/mosaic-stack",
"nativeContextVerified": true,
"sonnetFlagVerified": true,
"socket": "mosaic-fleet",
"newModelResponseTested": false
},
"existingProcessesRestarted": false,
"homeLaunchersModified": false
}
@@ -0,0 +1,19 @@
{
"issue": 1510,
"candidate": "/tmp/internal-team-r1-i9t21yzr",
"manifestSha256": "23a27014ce6f04ce8187d8495b2efe62b814c3c7d041b027f99b1ec4d490709d",
"files": [
"AGENTS.md",
"agents/README.md",
"agents/researcher/SOUL.md",
"agents/researcher/CONTEXT.md",
"agents/researcher/README.md",
"agents/researcher/launch.sh",
"agents/researcher/validate-sessions.mjs",
"scripts/test-darkwing-launch.mjs",
"docs/plans/2026-09-13_internal-development-bootstrap.md"
],
"tests": "node --test scripts/test-darkwing-launch.mjs scripts/test-rocko-launch.mjs",
"passed": 6,
"state": "ready for independent review"
}
@@ -0,0 +1,9 @@
{
"at": "2026-09-15T00:46:26.639Z",
"issue": 1512,
"startedAt": "2026-09-11T00:00:00.000Z",
"lastActivity": "2026-09-10T00:00:00.000Z",
"lastAssistantText": "OLD_FIXTURE_REPLY",
"oldReplyStillPresented": true,
"syntheticOnly": true
}
@@ -0,0 +1,6 @@
{
"discordDependencyCommit": "1ac812d3d5f3939221e7578502a10240ac82a6af",
"verifiedTrackedDependencyFiles": 31,
"sevenWorkingAndFrozenPinsUnchanged": true,
"scope": "byte comparison, not proof of snapshot construction history"
}
@@ -0,0 +1,58 @@
# #1512 inherited engine diagnosis
Rocko returned a read-only investigation. No source correction, live connector
operation or authority transfer follows from it. Filbert's seven source pins
remain the #1512 candidate; they do not change Discord engine code.
## A. Test synchronization and cleanup
Rocko reports the tool-turn test asserts engine.busy immediately after prompt
resolution at agent_end, while busy clears on a subsequent agent_settled event.
Separate pipe reads allow the assertion to run between events. Another basic
prompt test uses the same assumption. Success-only engine.stop cleanup leaves
the fake child and pipes open after an assertion fails, hiding the final spec
report and making the test file hang.
Darkwing independently ran Rocko's scratch relay with DELAY=50 and finally cleanup.
It failed 'busy after plain resolved', true versus false, and exited in 148ms.
Log: /tmp/darkwing-1512-race-confirmation.txt. This confirms the race mechanism,
not the exact historical assertion from the earlier truncated spec log.
Rocko's scratch directory:
/tmp/claude-1000/-mnt-storage-src-mosaic-stack/0422f20f-4d8c-43fe-b429-a2730cc1ab9d/scratchpad/1512/
Includes proxy-pi.mjs, race-repro.test.mjs, hang-repro.test.mjs,
hang-noforce.txt, hang-force.txt and stress/base-prev run logs.
He reports natural busy failures at lines56/75 under contention and deterministic
hang behavior without finally cleanup. Darkwing did not repeat load stress.
## B. Timeout test synchronization
Rocko reports a 100ms timeout test reads fake-pi's command log before the child
has created it or recorded abort under contention. That assertion can also skip
success-only cleanup. He observed this in both prior and row21 code. Not the
specific failed test named in Darkwing's first run.
## C. Separate engine behavior concern
Rocko reports a next-prompt refusal after a client timeout when a done pending
entry remains, busy is false and agent_start has not yet been consumed. prompt()
then omits followUp because it considers only pending entries not marked done.
The still-streaming fake engine refuses that prompt. This is a potential real
engine defect, not merely the busy assertion race. No live incident is established.
The row21 owner retains this code. Darkwing notified orch-01 on its verified named
socket and requested ownership/disposition, not a code takeover. A focused owner
fix needs regression coverage and review. Suggested direction from Rocko: account
for unsettled pending runs when choosing followUp; do not blindly change logic
from this note alone. Test cleanup and event synchronization also need correction.
## Provenance and gates
Rocko reports frozen Discord files match row21 commit1ac812d3, not the stated
archive basec4fc8e7d in HANDOFF. Darkwing requested Filbert's construction evidence
and append-only provenance correction outside frozen R1. Do not rewrite the old
handoff or infer its base from a mutable HEAD.
The later 351/351 bounded run remains valid narrow evidence; the earlier failure
is not erased. Integration stays held for owner disposition and provenance
reconciliation. No live action, publication, operator acceptance or GateF claim.
@@ -0,0 +1 @@
{"number": 1512, "creator": "darkwing", "charter": "docs/plans/2026-09-15_relaunch-activity.md"}
@@ -0,0 +1,89 @@
1e1aef2c74434e388d61b03407cd323336d20b7ef2106893d16a39638ee12a64 packages/control-board/package.json
8966f1f98ea81b4525345fc4889db5e0e5263ca41b5132ac8dd9bd99a2c88522 packages/control-board/src/discord.mjs
d6ac9b7b9661e6c85e912612b847c14ed38b7596b7db0a86a692da202f2a9f9c packages/control-board/src/serve.mjs
3f8822e97b2ba26b7035f86cf9bac421039957faab7f4543a1ed22c4568db23d packages/control-board/tests/attention-flow.test.mjs
5429fedc72af5cd9b7ddfa3b70e31aa7ecb4c511c51e0b640a16f9773912496a packages/control-board/tests/attention.test.mjs
64a67c91021de0c0ca583b65f8eed23e7e14415f2c18677b80754a7178363d04 packages/control-board/tests/discord.test.mjs
06a7eab5fa07ca83a210c5317e99ecccff8e8085197b7f66bbbecd67a4d8d541 packages/control-board/tests/scan.test.mjs
41bcaf8685437f130edc98aad92cfdb62e8201ee1861531ee9fa99069f681885 packages/control-board/tests/serve.test.mjs
ce0d74970496f197f507741261b8bf0b8e9c89909a44627eb11221ab407a6a8b packages/discord/README.md
587e01d440096c04ca8f4ae5ea096b4eab846989824f3effde12bbd66c71c6f9 packages/discord/extension/readonly-tools.mjs
d6a144b3c396c02e069a76d0d65bcd842a84f9de618b0172f1e1f7fed42757f9 packages/discord/fixtures/binding.example.json
4bfa50b150d49086e6438625c78261b58e6afb90601a9fb7ea464cc03faa29fb packages/discord/fixtures/claim-worker.mjs
012ce50053e0a38bcb2aebc4011c24ed41a93d4d7b79790f7ba0fc0049f0bc16 packages/discord/fixtures/legacy-owner-worker.mjs
a52c6bfc9bd26a3b021d3a96bddb0f5c45845d752ca4579ef04ef60667f110f3 packages/discord/package.json
573b015bc7a929903e33657f3cf79f0c6da1e927b3f9188f842ee867ec097d3f packages/discord/src/authorize.mjs
73e96f8421b88e3eb71666fb98673f10f659d0cd3535c5ac2897d3e3d431bab3 packages/discord/src/binding.mjs
a12c5a7b41060253ee12a3019f5ec930597f7866df247eb949bdf149223e5e93 packages/discord/src/cli.mjs
ba2132fe37d8ba4c04ea32e88aef7faae27fda4d2121426ab6c373aabe8b9b21 packages/discord/src/connector.mjs
18c583cb72c33bc7e41b9904dade98777e3e3fe8ff0820a56fc85404e8505904 packages/discord/src/context.mjs
67a5aca259731763caac0f199be8cae95c2606d740083eca2dff489b59353bf3 packages/discord/src/engine-pi.mjs
917b0e6c4f100e93f056b925e38d97eacdd8b2596e39fbd3f88c0b62b973d8ed packages/discord/src/errors.mjs
2d7f7fcbe62aac7b724a66714cd24d5634bd63f7660ef5979b8d4fccb0689b08 packages/discord/src/gateway.mjs
db33880f88dc2c7708015efecd0c6db604bc40387ea0dd6259d91f621a5b022c packages/discord/src/journal.mjs
1b83781c46cc8d9437b957e8757ea64a4da15a08d80706745770a06e411e72ff packages/discord/src/rest.mjs
063bbd5b47cd9f0d8a34adde09a5c63bd52d2d88be02a381662406ab07fcbe89 packages/discord/src/tools.mjs
a4ce74917ad68c82bd7ea07d6039813a94cec724c74f18e1e65a8f762d2cb7e1 packages/discord/systemd/[email protected]
1c1629800b1f1a417bff87e51f716cdc75a6725183a3f518096d21704de39804 packages/discord/tests/authorize.test.mjs
bcead9d2a44da3a12085dab2276f0afea5fd850061ac9dd9c4b64952d07712ee packages/discord/tests/binding.test.mjs
fc6eaa88db5d56d563ac2a83ace7db70c40cb328664c9782af8aaacf4ca4e978 packages/discord/tests/connector.test.mjs
b1cbc424a44d99d5ef6782fa4e17866030953ffa9a6e9ef97bac99d6b20dd576 packages/discord/tests/context.test.mjs
cb30bb0571756c66ff8177189f4722411245bf2f9b49b5a5d219f5009e0758f5 packages/discord/tests/engine.test.mjs
0930b8d13c10febd5ac98e69795c66a5313162c4e20d0e1f446ffdfc0c73bf64 packages/discord/tests/fake-pi.mjs
2fb4d80d228591d8b537a73995403c77c4fcc35fcfab3d8d8ad2705638825c71 packages/discord/tests/gateway.test.mjs
3da0370367aa6d7dea1e0e5edcc62350ad88399577f895b2bd29965e64360e2a packages/discord/tests/helpers.mjs
c70fc1da93e4bead261b2935098918804dfafd29efbf2e4a0256fadfc13c62b4 packages/discord/tests/journal.test.mjs
6ce9e42a35145df726449b01e2b89d9a468ee6bccc4cc754175e0e67f26f43af packages/discord/tests/recover.test.mjs
2c3c9b3a38f6aadb7c490f464573df3ad0dc207457838bdb727f4fbf29b69b75 packages/discord/tests/rest.test.mjs
a1c8203fecc706ea97896eff91a07145446440a7e0ca98b36ed2b038b30d5473 packages/discord/tests/tools.test.mjs
601db1de6d4838785f334dd93dd191d444276f306e016b3a4137659eaae76561 packages/ledger/README.md
e33fc5be5cdeb69f6f443a44c5b02a7bc346ac74dd69f984538e424a8d1b5de9 packages/ledger/src/cli.mjs
fbd95e45c5753cb44e39dfead0c6e0d2a5f07859dd9411f1dd659dde98af2ffc packages/ledger/src/ledger.mjs
c836ecd64068cccf7f515ab699c6a218e5ea1618c39e7e88be21bfa74d96414e packages/ledger/tests/gitea-helper.test.mjs
8e838a5333cbe6aafda5d72b810eb2840c4439973841d9c2bf5b55bc660455a3 packages/ledger/tests/ledger.test.mjs
bfaa5eed44c9eba9c195c38008db785d9c78e58f7544be7e46fd32155708f256 packages/mosaic/README.md
12838980706273f2a3daf84a1a78d55e06018c1f78587cc9db4527a8aa8b074f packages/mosaic/package.json
dd07bea32281e66d0f7d8485595eb403305fac1ca5943daa67a73bc0666caecc packages/mosaic/src/cli/main.mjs
ed9920d465f74387566443544131226050dec8f13efefaad2d48157c8808f162 packages/mosaic/src/execution.mjs
fa5c5fe977916418e7d2c4f6ceeedb6f3bcb6ae854f680bec07f7930b937b1f4 packages/mosaic/src/fixture-store.mjs
ac64c9c32ba928cc557107a47e127350efd724a7d8cdf1f399630964fe621879 packages/mosaic/src/index.mjs
7a3e66fc20e3e51d838c66e2cb0f3c2b73047ffabccb93532bd325a8a65e68c2 packages/mosaic/src/materialize-fixture.mjs
e4d2e55e077f38c5523d0211e6ec90e301804d8f6896674de2bbeaf0b049be58 packages/mosaic/src/records.mjs
28f87b70d0d42d4f4f25a7d72f138e87dcfcd79d542b43da7fb208444f4deeb7 packages/mosaic/src/refresh-fixture.mjs
d33985227f536117bcfad1d7f5ecd777adf02c073c8a9bff634c4b32f2b85e48 packages/mosaic/src/registry.mjs
545c126314e14fb772dbe082adcec554e84bbfdef8f93e231ae825ed5b8ec845 packages/mosaic/tests/fixtures/valid/auth/accounts/openai-codex/homelab-openai/account.json
3bcf68dd33fd1e5b219ffaf3a15880718cb3751ac07f132d2466c408e2fda6f3 packages/mosaic/tests/fixtures/valid/auth/providers/ollama-remote.json
d214e39539b5de9d6f2350b5d681cfa340ff2d255c215d25499aeb10711fcd7f packages/mosaic/tests/fixtures/valid/auth/providers/openai-codex.json
e986f8fd65a5a501510dc6e706d772a5c0ddc336864ff6c41405f3901749ffe1 packages/mosaic/tests/fixtures/valid/auth/settings/research-default.json
ff1b45df2503ccd796c220364ea217c5eb1ab1eaf22ffa8094ff0851dfb20ed3 packages/mosaic/tests/fixtures/valid/harnesses/pi.json
44bcb47057dbaaff69992c8e7355b8195f3db6f8ccb7bd05bb2ea913e3940ea3 packages/mosaic/tests/materialize.test.mjs
7747bace9da600ef93776b3e473efdd3f1f57420aaa3fff207a1fd0c6c5b5e0f packages/mosaic/tests/refresh.test.mjs
1c2f018de202003ddf69f333fc1c066d265b312a431007742aebd7ccca5a93a7 packages/mosaic/tests/registry.test.mjs
1891119852df1930505bbec6e0543b95f82d4ff3834956fe433d9f58c3204645 packages/mosaic/tests/safety.test.mjs
4bb04a3188ab9c5c985206da83bc1cc6f9be042a072aad75224560e7acf57e29 packages/seat/README.md
26ee149f870ff56ad6d1c3d7c3b4b2301879c59f02d5840078c07e7964cc2175 packages/seat/package.json
a2880bd7b66cd9d6fc3588cc739eadc5db7fe806f81da86e0ac09bbff15a3099 packages/seat/src/cli.mjs
eec970961a667c9e614d1474d33c31a019b2cf1b0bf931a51458d874bca13a15 packages/seat/src/seat.mjs
c06f2fc951ce56c905a98175dc82966893a2ba801405fb407c6c59363d029288 packages/seat/tests/seat.test.mjs
6eca43194632017ffed73d62ba76c74435c4b661f4ec3fc76d8b0bae8bcf9089 packages/webui/README.md
ee81d2b902a0b250f32d997d27085b9c76a7c98acdd0a0b55c65cf9342047b4e packages/webui/package.json
20f8091e5fb3fcaba583b601ce258999d44afbe569e93c979938ace759b4eef4 packages/webui/src/cli.mjs
a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-400.woff2
a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-500.woff2
a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-600.woff2
a30ddcd349703aff7464c34bef3fffdff405ee50c113440d7c8693c02d210972 packages/webui/src/public/assets/fonts/manrope-700.woff2
e01b637272e0cbdfb240184dd98ea5cc671556d9894dae2668d92ab2c906787c packages/webui/src/public/assets/fonts/manrope-OFL.txt
0d9aaaaa963b675d94d8fbee0cf47a8ecf03ea412f8b14aba96416375e5a2bb5 packages/webui/src/public/assets/fonts/manrope-sources.txt
d568df6dca14ae336db3b627639c8181c088649a21ffe992f9bfacc20b5b6238 packages/webui/src/public/brand.js
1ce47447339a274dd51c0168838a5e58e0addaf2200e04625bc1da5bc9279097 packages/webui/src/public/console.css
db055d5b8a3710f4164193550ac25b7c5d223dd890ae3e9d73daaada38849557 packages/webui/src/public/index.html
7add449911677c662a05ad5d9c2462f919e759574fe4b85bd13a705cc54f6856 packages/webui/src/public/live.css
b3e6e240fa93555e30321f897405839856b3438ef44b1cea73d2f5fbd899077e packages/webui/src/public/shared/app.css
c0c1a360ce44a7439e67eea36b9237c59621aa3fd78571a823fd23dc15a950f6 packages/webui/src/serve.mjs
b1c2860e69bb099fda476c291c41adddf736dcaf99b68620d5401ce92396336a packages/webui/tests/browser-edge.test.mjs
3ff14de07e87cc624433db6425b9d05e40d98f61e40f2bbf1fbee7431f9d73c1 packages/webui/tests/browser.mjs
01636f05a8a59e8aaf7da000e631b83718b79a5e5bbc36d5b67738215cc250a7 packages/webui/tests/browser.test.mjs
24cf26c357164857f29be4f47204812d560310ee79dfbfebd7aa48f7a84ba9b0 packages/webui/tests/discord.test.mjs
0aea0461d9a63b43e7cd2fea7cbaf335af48470b01481d3507a48bce8c355f79 packages/webui/tests/fixture.mjs
15e77422890bc50cdb700484ca7e16ac32476aa8ec61d5575a6e0a920694bc4c packages/webui/tests/serve.test.mjs
2c822c787f1e6e5779aa74a5c054845de31f5f990e5a4b97a1398b9aaa856a7e scripts/test-discord.sh
@@ -0,0 +1,17 @@
{
"recordedAt": "2026-09-15T01:13:19.864188+00:00",
"author": "Filbert",
"issue": 1512,
"kind": "append-only provenance correction; supersedes only HANDOFF archive-base statement",
"frozenCandidate": "/tmp/relaunch-activity-r1-8ckPxKjN",
"candidateManifestSHA256": "47769fada74c5684dd0ca32c41cea9dcf60e039f5a07657546e17a240af13e9d",
"incorrectEarlyCheckpoint": "c4fc8e7d7f76b2b8cda6a6a0f6fc760d37235c98",
"verifiedArchiveContentBase": "1ac812d3d5f3939221e7578502a10240ac82a6af",
"construction": "git archive HEAD | tar -x -C \"$SNAP\", then working packages/control-board, packages/webui and packages/seat overlays, charter and red log copies. HEAD was not pinned in the archive command. The earlier checkpoint was incorrectly reused in HANDOFF.",
"verification": "All 4509 archive files outside the three overlay package directories match 1ac812d3 byte-for-byte, with no differences or missing files. c4fc8e7d comparison had 21 differing files and predates additional new files. All tracked Discord files and scripts/test-discord.sh match 1ac812d3. Seven candidate hashes still match original manifest.",
"dependencyManifest": "DEPENDENCIES.sha256",
"dependencyManifestSHA256": "deae75b488b2f9172e7168bba83e442a251c294464ee6200527c23ef4599f968",
"dependencyFiles": 89,
"dependencyScope": "All files in six tested packages except seven authored candidate paths, plus scripts/test-discord.sh, pinned from frozen R1 rather than current checkout.",
"limits": "R1 and all prior evidence unchanged. Prior 351-pass serialized receipts describe this actual dependency set, not c4fc8e7d dependencies. Intermittent inherited engine/fixture failure and timeout remain recorded and not green. No source changes, live operations, publication or Gate F approval."
}
@@ -0,0 +1,43 @@
# #1512 R1 source review
Filbert author. Frozen /tmp/relaunch-activity-r1-8ckPxKjN, seven files.
CANDIDATE.sha256 manifest
47769fada74c5684dd0ca32c41cea9dcf60e039f5a07657546e17a240af13e9d.
Darkwing independently verified all seven working/frozen pins and eleven
inherited test pins, read RED.txt and reviewed the scoped scanner/CLI/UI delta.
Darkwing approves the seven source changes. Relaunch notice requires positive
row and matching-registration PID liveness, matching session path, and strictly
newer finite timestamps. Connector registration is excluded. Existing historical
fields, state, Seen, attribution and reply gates remain unchanged. CLI and both
presentations replace current activity with the notice and label retained history.
This does not establish native process-incarnation authentication or resolve old
waiting/error state.
Dewey independently APPROVED scoped UX on the same seven pins. Frozen targeted
scanner/browser suites 8/8 passed, four Chromium cases and 330 contrast samples.
Both fixture pages at 320px preserve history, show the notice without page overflow,
and resume normal presentation after new activity. Historical error label reviewed
in source; the browser fixture uses waiting, not error. No screenshot-based visual
review, real relaunch, live observation or Gate F evidence claimed.
## Verification qualification and integration hold
Darkwing's first exact serialized six-package run timed out at 120 seconds.
/tmp/darkwing-relaunch-r1-tests.txt records an inherited Discord engine test
failure, 'a run with tool turns settles once', before the hang. It is NOT green.
No test runner remained after the harness timeout. Detailed assertion output was
not captured by that reporter before the hang.
An isolated TAP diagnostic of that engine case passed, using test-force-exit and
a 5-second test timeout. This is diagnostic only, not full-suite acceptance.
/tmp/darkwing-relaunch-engine-probe.txt.
A full frozen TAP run with test-concurrency=1 and test-timeout=15000, WITHOUT
force-exit, passed 351/351, zero failed/cancelled/skipped, in 14.27 seconds.
/tmp/darkwing-relaunch-r1-diagnostic.txt. The first failure remains unexplained;
the later pass does not erase it or establish default-concurrency success.
Hold integration while Rocko performs bounded read-only diagnosis of the inherited
engine failure. No author source correction requested yet. No live changes,
publication, operator acceptance or Gate F claimed. Preserve R1 and prior freezes.
@@ -0,0 +1,95 @@
# #1512 R1 candidate re-run against HEAD, 2026-09-26
Requested by Sage over T3 on 2026-09-26. Integration was held because of the
row 21 engine test race, and `1685deb4` fixed that race. This record is new
evidence. The R1 files next to it are unchanged.
## Snapshot
- `/tmp/relaunch-activity-r1-rerun-oupRiqIC`: `git archive 21e3e908`, plus the
working `packages/control-board`, `packages/webui` and `packages/seat`
(node_modules excluded), plus a symlink to the root `node_modules`.
- `CANDIDATE.sha256` in the snapshot hashes to `47769fad…`, which is the R1
candidate manifest. All 7 candidate pins match.
- 71 of the 89 R1 dependency pins are unchanged. The other 18 are
`packages/discord/**` and `scripts/test-discord.sh`, and they equal HEAD.
`engine-pi.mjs`, `engine.test.mjs`, `fake-pi.mjs` and `helpers.mjs` in the
snapshot are byte-identical to HEAD.
- Node v26.8.1. Test union: control-board, webui, seat, mosaic, ledger and
discord tests. The count rose from 351 at R1 to 397 because rows 23 to 25
added Discord tests.
## Results
| Run | Flags | Result | Log (`/tmp/`) | sha256 prefix |
|---|---|---|---|---|
| A1 | `--test-concurrency=1 --test-timeout=15000` (R1 form) | 397/397, rc 0, 19.9 s | darkwing-1512-rerun-A1-bounded.txt | 8b34a64a660d5948 |
| A2 | same | 397/397, rc 0, 19.7 s | darkwing-1512-rerun-A2-bounded.txt | 5c8b96209f5b8b2c |
| A3 | same | 397/397, rc 0, 19.4 s | darkwing-1512-rerun-A3-bounded.txt | e9829f454bf2b63f |
| B1 | `--test-concurrency=1` | 397/397, rc 0, 19.0 s | darkwing-1512-rerun-B1-serial-notimeout.txt | bb6eaedf6250c410 |
| C1 | default concurrency | 395/397, rc 1, hung until I ended a leaked child | darkwing-1512-rerun-C1-default-concurrency.txt | 5200188e1bcd8b15 |
| C2 | default concurrency | 395/397, rc 1, hung until I ended a leaked child | darkwing-1512-rerun-C2-default-concurrency.txt | 1f244a4835000e31 |
In C1 and C2, the same two tests failed, both in `packages/discord/tests/engine.test.mjs`:
- Test 205 (line 100), "a prompt while streaming is held…": ENOENT on
`commands.jsonl`.
- Test 208 (line 146), "tool events from a run that outlived its timeout…":
`engine refused prompt: agent is streaming; specify streamingBehavior`.
Both runs stopped making progress after every test had finished and the
`after()` hook had removed the temp root. The only thing left was a `fake-pi.mjs`
child. I sent that child SIGTERM (C1 pid 3636242, C2 pid 3668771). The file
then exited and the runner printed its summary. Apart from that, I didn't
touch either run.
## Control: clean HEAD, no #1512 files
Snapshot `/tmp/darkwing-head-21e3e908-zinKkQ` (`git archive 21e3e908` only).
Same union, default concurrency:
| Run | Result | Log (`/tmp/`) | sha256 prefix |
|---|---|---|---|
| 1 | rc 124 at the 240 s cap, 178 ok, interrupted in seat and webui files | darkwing-head-default-1.txt | 799acad299d0b20e |
| 2 | rc 124 at the 240 s cap, same shape | darkwing-head-default-2.txt | e9028b28c02ffc86 |
| 3 | 369/370, rc 1, test 187 = the line 146 engine failure | darkwing-head-default-3.txt | fcd85bd474f2c210 |
| discord only | 162/162, rc 0 | darkwing-head-discord-only.txt | 5f068557caee7fc7 |
| seat + webui only | 21/21, rc 0 | darkwing-head-seat-webui.txt | 9952b57707577a92 |
`engine.test.mjs` alone in the candidate snapshot passed 5 of 5 times, 11/11
each run (`darkwing-engine-alone-{1..5}.txt`).
Dewey was running `packages/webui/tests/` from the canonical checkout during
part of this window. That added load. It doesn't explain the HEAD control, and
the two engine failures reproduce without it.
## Reading
The #1512 candidate passes its R1 acceptance command 3 of 3 times at 397/397,
and it passes the serial run with no timeout. Every default-concurrency failure
is in committed `#1509` Discord engine code, and clean HEAD fails the same way.
I found nothing that implicates the seven candidate files.
Two engine defects are exposed under load. They belong to `#1509`, not to #1512:
1. **Test race plus leak, `engine.test.mjs:100`.** The test reads the fake's
command log 20 ms after the second prompt. Under load the fake hasn't written
the file yet, so the read fails with ENOENT. The test has no `try/finally`,
so `engine.stop()` never runs, the fake-pi child stays alive, and the test
file never exits. That is the hang. The fix is the pattern `1685deb4`
already uses elsewhere: wait with `until()` and stop in `finally`. Six other
tests in the file also stop without `finally`.
2. **Engine race, `engine-pi.mjs`, seen through `engine.test.mjs:146`.**
`busy` is `state.busy || pending.some((t) => !t.done)`. Suppose a turn times
out before the engine has read pi's `agent_start`. `failTurn` marks it done,
`state.busy` is still false, and `busy` reads false. The next prompt then goes
straight to pi, and pi refuses it because it's still streaming. This fails
closed: the prompt errors and nothing is misattributed. Production timeouts
are long, so this should be rare there. It's still a real race. One option is
to count a sent turn toward `busy` until pi's own end or settle event arrives,
not only until the client gives up on it.
Rocko's finding C, a follow-up sent after a timeout, looks superseded. Since
`1685deb4`, the engine never sends a follow-up (held prompts), and the line 100
test asserts that `streamingBehavior` is absent. Defect 2 is the timeout race
that remains.
@@ -0,0 +1,18 @@
{
"at": "2026-09-15T00:11:38.757844+00:00",
"newBackend": [
3414098,
"76594640"
],
"health": true,
"pageMatchesReviewedR2": true,
"allRowsHaveAttributionField": true,
"inapplicableAttributionNull": true,
"legacyRegisteredUnknownCount": 2,
"agentPaneIdentitiesUnchanged": true,
"connectorIdentityUnchanged": true,
"allRegistrationBytesUnchanged": true,
"rocko": [],
"rockoWritePerformed": false,
"blocker": "Rocko registration PID 185602 is dead; native context/lock PID is 3707667. No identity repair authorized."
}
@@ -0,0 +1,2 @@
{"at": "2026-09-15T00:10:51.631297+00:00", "event": "owner-authorized-restart-intent", "oldBackend": [3769124, "72871926"], "agents": {"default/darkwing": [[2733924, "12863634"]], "default/dewey": [[934346, "466065"]], "default/filbert": [[72183, "100870"]], "default/researcher": [[173699, "66404285"]], "mosaic-fleet/rocko": [[90599, "128275"]]}, "connector": [3022843, "67887873"], "registrationHashes": {"seats/repo/darkwing/registration.json": "4eda287e7f3bee8967052d90d5a15fb8bb61457e25991192bfaffcaba92c6075", "seats/repo/filbert/registration.json": "efe469ed81855c7e4d4667146a2e520c3a5f953a534cf4a90d6739d2e6335767", "seats/repo/rocko/registration.json": "2195325104fdaf13e5a8d351143f6c046144b9b99865d18b21d01564ac23b327", "seats/repo/dewey/registration.json": "92960db9f3f3d5c56c5cc79d23f39b3df3f1dcdeaae61bccbf0e62d98563aa6c", "seats/repo/researcher/registration.json": "77816e560918d2a9534ff2c97ef201605fbb5b6ef8e341d4dd948684082563fa"}, "configHash": "8e6b3a4049c7ab11ab6d842d974da0bcf07985cf4db988995436ed4b94b44338", "rockoWriteBlocked": {"registeredPid": 185602, "nativePid": 3707667}, "pinsVerified": 18}
{"at": "2026-09-15T00:10:51.650299+00:00", "event": "replacement-started", "newPid": 3414098, "oldExitedGracefully": true, "log": "/tmp/task-attribution-backend-gmaofryr.log"}
@@ -0,0 +1,7 @@
{
"at": "2026-09-14T14:08:03.786Z",
"issue": 1511,
"exit": 4,
"explicitByRejected": true,
"liveFilesChanged": false
}
@@ -0,0 +1,34 @@
# Proposed #1511 local test envelope
Original proposal retained below. Jason authorized the backend update, completed
2026-09-15. Rocko's stale registration blocked that target before mutation.
Jason then explicitly authorized Filbert instead and confirmed Claude console
integration remains separate. The Filbert metadata update and live API checks
passed; operator visual acceptance is pending. Receipt: live-test.jsonl.
No Rocko registration or process was changed.
- Reverify the approved R2 pins and current backend PID/start/command/config.
Record the five native agent identities and connector service identity.
- Restart only the control-board backend on loopback 7331, using its existing
command/config. Do not restart agents, WebUI or the Discord connector.
- Verify health and that the updated reader accepts existing records. Check
actual asset loading before claiming either presentation loaded R2.
- For the visual test only, update the repository-native Rocko registration's
task to '#1511 task attribution user test', with explicit setter darkwing.
Use the reviewed seat task command and explicit repo layout. First verify the
registration matches the intended native session; a mismatch blocks the write.
- Preserve the original registration privately outside the repository. Never
print its contents. Assert only task, taskSetBy and updatedAt change, with all
identity fields and startedAt preserved. No other registration may change.
- Verify API selection and ask Jason to inspect the 'set by darkwing' tag and
caller-claim wording. Legacy registered attribution remains unknown; inferred
and connector tasks remain not applicable. Agent/connector identities unchanged.
Rollback: before any attribution write, an incompatible old reader can be restored
only from the retained prior artifact. After the one authorized write, restoring
an old reader requires restoring that record's original task metadata first, with
an exact unchanged-current-record check. If anything else changed, stop rather
than overwrite it. Keep the compatible reader while resolving the conflict.
No broad metadata migration or automatic rollback over concurrent edits.
This envelope does not authorize publication or the later Filbert behavior task.
@@ -0,0 +1,3 @@
{"at": "2026-09-15T00:36:13.204764+00:00", "event": "owner-authorized-filbert-test-write", "target": "repo/filbert", "backup": "/tmp/task-attribution-filbert-k3o7zimk/registration-before.json", "nativeIdentity": [793364, "56603274"], "backend": [3414098, "76594640"], "agents": {"default/darkwing": [[2733924, "12863634"]], "default/dewey": [[934346, "466065"]], "default/filbert": [[72183, "100870"]], "default/researcher": [[173699, "66404285"]], "mosaic-fleet/rocko": [[90599, "128275"]]}, "connector": [3022843, "67887873"], "beforeHash": "efe469ed81855c7e4d4667146a2e520c3a5f953a534cf4a90d6739d2e6335767"}
{"at": "2026-09-15T00:36:13.852595+00:00", "event": "verified", "agent": "filbert", "task": "#1511 task attribution user test", "taskSource": "registration", "taskSetBy": "darkwing", "afterHash": "1bc90238adcb0210fcd027d76ac2d87e292d5fe29e6892d4c3efad4fddb93edd", "onlyAllowedTaskMetadataChanged": true, "allOtherRegistrationsUnchanged": true, "agentAndConnectorIdentitiesUnchanged": true, "backendUnchanged": true, "operatorVisualAcceptance": "pending"}
{"at": "2026-09-15T00:47:20.173624+00:00", "event": "operator-visual-confirmed", "tableEvidence": "/tmp/pi-clipboard-afe5713e-77ed-4be6-9a24-d455178ed1f0.png", "inspectorConfirmed": "darkwing (as claimed by the caller, not verified)", "scope": "bounded local attribution delivery; publication and Gate F separate"}
@@ -0,0 +1,15 @@
d289ec7d3a45c541346ece6b32908d75efe4ca0ad47df729bcf493d9bc9be718 packages/seat/src/seat.mjs
a2880bd7b66cd9d6fc3588cc739eadc5db7fe806f81da86e0ac09bbff15a3099 packages/seat/src/cli.mjs
4bb04a3188ab9c5c985206da83bc1cc6f9be042a072aad75224560e7acf57e29 packages/seat/README.md
c06f2fc951ce56c905a98175dc82966893a2ba801405fb407c6c59363d029288 packages/seat/tests/seat.test.mjs
a40c57d011ce54956856c77e6758ca12dcd0ffd4be0d53ae2707b7d3fe3708b1 packages/control-board/src/scan.mjs
7f9b3b684e8af9f933d23cab956d32aad345b61be5001f8f6f591d3a91c57d12 packages/control-board/src/page.html
5f3bf97fd90045b2b1f08f2922ec0f6b33b3729bef39f9bfcbe0229dcd6e2080 packages/control-board/README.md
06a7eab5fa07ca83a210c5317e99ecccff8e8085197b7f66bbbecd67a4d8d541 packages/control-board/tests/scan.test.mjs
41bcaf8685437f130edc98aad92cfdb62e8201ee1861531ee9fa99069f681885 packages/control-board/tests/serve.test.mjs
64a67c91021de0c0ca583b65f8eed23e7e14415f2c18677b80754a7178363d04 packages/control-board/tests/discord.test.mjs
772d564b974de9d30df296d538e7878ceb2421f479ed07285456e7d43dfef8b9 packages/webui/src/public/app.js
0aea0461d9a63b43e7cd2fea7cbaf335af48470b01481d3507a48bce8c355f79 packages/webui/tests/fixture.mjs
d45b138bdb5a3eaf6cf2470b7b2beed16de0a635ad1868c8d25336b4040fe163 packages/webui/tests/browser.test.mjs
d39e5d1e1b7e3d4aa6689e20faabeee6691656e8661f866d98731912eb140d06 packages/webui/tests/browser-edge.test.mjs
c97fd047a970326c096a4771f9bd49496fb6ff244047ea5c1e59bdd7adaeefa2 packages/webui/tests/discord.test.mjs
@@ -0,0 +1,46 @@
# #1511 R1 combined review
Author Rocko. Frozen candidate /tmp/task-attribution-r1-frozen-sRoqqr5m.
15-file manifest: r1-manifest.sha256, SHA256
0185cc65c973f6d89c394e2e9fba67fccd794b8ad2be2720a8bb3700b2b018a3.
Darkwing independently matched every author pin against frozen and working files.
The author snapshot contains selected files, not a standalone runnable checkout.
Both independent lanes returned before correction authorization.
## Filbert backend verdict
APPROVE AS SOURCE for exact R1, subject to separate presentation review.
Reported independent serialized seat/board/WebUI 141/141 in both working tree
and dependency-complete overlay /tmp/filbert-task-attribution-review-EaqP6CYc.
Log: TASK-ATTRIBUTION-TESTS.txt in overlay. Working launcher regressions 6/6,
/tmp/filbert-task-attribution-launchers.txt. Additional invalid-environment and
control-character probes passed. All 15 pins matched after tests.
Confirmed optional v1 compatibility, precedence/refusal rules, preservation of
other fields and task-selected attribution only. No backend blocker.
Nonblocking comment correction: bounded identifier syntax does not guarantee
absence of private identifiers. Numeric IDs and email-like names still fit.
Remove that inaccurate privacy claim in a separately pinned R2.
## Dewey UX verdict
REQUEST CHANGES, R1-U1. WebUI inspector maps null/inapplicable attribution to
unknown, conflating a transcript/connector task with a legacy registered task
whose setter is unknown. Preserve null as not applicable or a missing-value
marker. Keep unknown for a selected legacy registration. Add assertions for
legacy unknown, transcript/Discord null, and transition from a known setter
without stale attribution. Existing edge expectation currently enshrines the bug.
Dewey independently ran matching working-tree browser, browser-edge and Discord
suites, 3/3 passed, 330 contrast samples. Frozen/working pins matched before and
after. No screenshots, live observation or full backend verdict claimed.
Escaping, placement and reply boundaries otherwise passed his scoped review.
## Disposition
R1 is not approved for integration. Preserve its snapshot and manifest. Rocko
corrects R1-U1 and the inaccurate comment, reruns applicable checks, and supplies
R2 with changed/inherited pins. Both review lanes then verify the exact R2.
The missing docs/TOOLS.md usage flag is required additive documentation; preserve
other owners' dirty edits when adding it. No Gate F, live change or publication.
@@ -0,0 +1,15 @@
eec970961a667c9e614d1474d33c31a019b2cf1b0bf931a51458d874bca13a15 packages/seat/src/seat.mjs
a2880bd7b66cd9d6fc3588cc739eadc5db7fe806f81da86e0ac09bbff15a3099 packages/seat/src/cli.mjs
4bb04a3188ab9c5c985206da83bc1cc6f9be042a072aad75224560e7acf57e29 packages/seat/README.md
c06f2fc951ce56c905a98175dc82966893a2ba801405fb407c6c59363d029288 packages/seat/tests/seat.test.mjs
a40c57d011ce54956856c77e6758ca12dcd0ffd4be0d53ae2707b7d3fe3708b1 packages/control-board/src/scan.mjs
7f9b3b684e8af9f933d23cab956d32aad345b61be5001f8f6f591d3a91c57d12 packages/control-board/src/page.html
5f3bf97fd90045b2b1f08f2922ec0f6b33b3729bef39f9bfcbe0229dcd6e2080 packages/control-board/README.md
06a7eab5fa07ca83a210c5317e99ecccff8e8085197b7f66bbbecd67a4d8d541 packages/control-board/tests/scan.test.mjs
41bcaf8685437f130edc98aad92cfdb62e8201ee1861531ee9fa99069f681885 packages/control-board/tests/serve.test.mjs
64a67c91021de0c0ca583b65f8eed23e7e14415f2c18677b80754a7178363d04 packages/control-board/tests/discord.test.mjs
954d98fa2e6b264ed751857f0b76bb36c966c1ef59e4b091eb0b5351e84783d7 packages/webui/src/public/app.js
0aea0461d9a63b43e7cd2fea7cbaf335af48470b01481d3507a48bce8c355f79 packages/webui/tests/fixture.mjs
01636f05a8a59e8aaf7da000e631b83718b79a5e5bbc36d5b67738215cc250a7 packages/webui/tests/browser.test.mjs
b1c2860e69bb099fda476c291c41adddf736dcaf99b68620d5401ce92396336a packages/webui/tests/browser-edge.test.mjs
24cf26c357164857f29be4f47204812d560310ee79dfbfebd7aa48f7a84ba9b0 packages/webui/tests/discord.test.mjs
@@ -0,0 +1,41 @@
# #1511 R2 source approval
Author Rocko. Frozen /tmp/task-attribution-r2-frozen-3PqORMJP.
15-file manifest r2-manifest.sha256, SHA256
d4c963952b3f2b0e1c99133b3fe3fefa4c1883b79a009dc1f8d20ef58b46ca16.
Darkwing verified all working/frozen pins and all retained R1 pins. Exactly five
changed files, ten inherited. R1 remains unchanged.
Filbert independently APPROVED backend/schema/tests as source. He verified
working/frozen/overlay pins after 141/141 serialized seat/board/WebUI checks in
both working tree and /tmp/filbert-task-attribution-r2-review-v6KDM7pv.
Log: R2-TESTS.txt there. Privacy-syntax comment finding closed.
He also approved only Darkwing's additive TOOLS usage patch, SHA256
86bcba3c2889b12151aefa1fa266fff81bb91126450b2deeb7ca519869ff2253.
Other dirty TOOLS changes are not adopted or approved by that verdict.
Dewey independently APPROVED scoped source UX. R1-U1 CLOSED. Null stays not
applicable, legacy registration unknown stays unknown, and transition assertions
remove prior setter names. Matching working-tree browser/browser-edge/Discord
checks 3/3 passed, 330 contrast samples. No screenshots or live visual observation.
He rechecked all 15 pins after tests. Dependency context was the working tree.
Darkwing additionally ran the broader working-tree command:
node --test --test-concurrency=1 packages/control-board/tests/ packages/webui/tests/ packages/seat/tests/ packages/mosaic/tests/ packages/ledger/tests/ packages/discord/tests/
344/344 passed, exit 0. Log /tmp/darkwing-task-attribution-r2-tests.txt.
This is serialized local verification, not CI or default-concurrency success.
## Reader compatibility and remaining gate
Filbert and Darkwing independently reproduced the old strict-v1 reader rejecting
a new taskSetBy field. New readers accept both old and new records.
Darkwing's synthetic-only receipt is reader-compatibility.json; no live record
was used for that probe. Optional v1 is backward-read compatible, not old-reader
forward compatible. Update cached readers before any live attribution write.
Do not roll an old reader back over new records without an approved restoration.
Source approval is complete. Live reader update, operator testing and publication
remain separate. Gate F and the Filbert stale-relaunch assignment are not started
or claimed by these approvals. No agent or connector restart is authorized here.
@@ -0,0 +1,8 @@
{
"at": "2026-09-14T14:28:14.532Z",
"oldReaderRejectsNewField": true,
"newReaderAcceptsOldAndNew": true,
"syntheticOnly": true,
"liveRegistrationReadsOrWrites": 0,
"requirement": "Update cached readers before first live attribution write; old-reader rollback is incompatible with new records."
}
@@ -0,0 +1,5 @@
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -137 +137 @@
-scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
+scripts/mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]
@@ -0,0 +1,161 @@
# Darkwing dirty-tree manifest, 2026-09-26
Requested by Sage (lead) over T3 on 2026-09-26. Nothing listed here is
committed. Sage reviews this list before anything lands, because AGENTS.md,
QUEUE.md and SESSIONS.md mix edits from both of us.
Baseline: HEAD `21e3e908` (the comms commit), branch `refactor`. All seven
suites passed on this tree at 19:31Z: config 24, task 90, foundation 43,
conductor 17, release 14, auth 15, discord 63.
"Pin match" means the working file's SHA-256 equals a hash recorded in an
approval I can still read. The frozen `/tmp` review copies from 09-13 to 09-15
are gone. The pins survive in `agents/darkwing/work/*` and in my Pi session log
`.pi/state/darkwing/sessions/2026-09-12T16-44-19-503Z_*.jsonl`.
Excluded on Sage's instruction: `agents/dewey/work/wui/**`, the 23 `aws-*`
directories and other untracked `skills/**`. Also excluded, because they are
Sage's own work: `agents/sage/work/coordination/**`.
## 1. Code: rows 18, 22, #1511 and #1512 are one cumulative state
`packages/control-board`, `packages/webui` and `packages/seat` carry four
approved rows layered on each other. `scan.mjs`, `page.html`, `README.md` and
`app.js` contain edits from several rows, so the rows cannot be committed
separately without rebuilding intermediate states that no longer exist on disk.
The working tree equals the #1512 R1 frozen tree exactly for these three packages.
Recommendation: one commit covering all four rows, citing each receipt.
| Path | Rows | Pin match | Ready |
|---|---|---|---|
| packages/control-board/README.md | 22, #1511, #1512 | #1512 R1 candidate | with #1512 |
| packages/control-board/src/scan.mjs | 18, 22, #1511, #1512 | #1512 R1 candidate | with #1512 |
| packages/control-board/src/cli.mjs | 18, #1512 | #1512 R1 candidate | with #1512 |
| packages/control-board/src/page.html | 18, #1511, #1512 | #1512 R1 candidate | with #1512 |
| packages/control-board/src/serve.mjs | 18 | #1512 R1 dependency | with #1512 |
| packages/control-board/src/discord.mjs (new) | 18 | #1512 R1 dependency | with #1512 |
| packages/control-board/tests/scan.test.mjs | 22, #1511 | #1511 R2 and #1512 dep | with #1512 |
| packages/control-board/tests/serve.test.mjs | 18, 22, #1511 | #1511 R2 and #1512 dep | with #1512 |
| packages/control-board/tests/attention.test.mjs (new) | 22 | #1512 R1 dependency | with #1512 |
| packages/control-board/tests/attention-flow.test.mjs (new) | 22 | #1512 R1 dependency | with #1512 |
| packages/control-board/tests/discord.test.mjs (new) | 18 | #1511 R2 and #1512 dep | with #1512 |
| packages/control-board/tests/relaunch.test.mjs (new) | #1512 | #1512 R1 candidate | with #1512 |
| packages/webui/src/public/app.js | #1511, #1512 | #1512 R1 candidate | with #1512 |
| packages/webui/tests/fixture.mjs | 22, #1511 | #1511 R2 and #1512 dep | with #1512 |
| packages/webui/tests/browser.test.mjs | #1511 | #1511 R2 and #1512 dep | with #1512 |
| packages/webui/tests/browser-edge.test.mjs | #1511 | #1511 R2 and #1512 dep | with #1512 |
| packages/webui/tests/discord.test.mjs (new) | 18 | #1511 R2 and #1512 dep | with #1512 |
| packages/webui/tests/relaunch.test.mjs (new) | #1512 | #1512 R1 candidate | with #1512 |
| packages/seat/README.md | #1511 | #1511 R2 and #1512 dep | with #1512 |
| packages/seat/src/cli.mjs | #1511 | #1511 R2 and #1512 dep | with #1512 |
| packages/seat/src/seat.mjs | #1511 | #1511 R2 and #1512 dep | with #1512 |
| packages/seat/tests/seat.test.mjs | #1511 | #1511 R2 and #1512 dep | with #1512 |
Receipts:
- Row 18 (#1509 board row): exact R3 source approvals from Darkwing and Dewey
in Gitea comment 26257, R3 manifest `254403b8…`. Jason accepted the visual
test ("parfait"). `agents/darkwing/work/discord-board/r3-backend-review.json`.
- Row 22 (#1503 attention): Filbert approved exact R1, comment 26248, R1
manifest `e40b58ec…`. Live restart receipt 26249.
- #1511 (row 6 code phase): R2 approved by Filbert (backend) and Dewey (UX),
manifest `d4c96395…`. Operator confirmed table and inspector.
`agents/darkwing/work/task-attribution/r2-review.md`.
- #1512 (row 6 pilot): Darkwing backend and Dewey UX approved R1, candidate
manifest `47769fad…`. The seven working files reproduce that manifest hash
exactly. 71 of 89 dependency pins are unchanged. The other 18 are
`packages/discord/**` and `scripts/test-discord.sh`, which moved with
committed rows 23–25. Integration was held on the row 21 engine test race.
The re-run against HEAD is in
`agents/darkwing/work/relaunch-activity/rerun-2026-09-26.md` (new, ready
with #1512 after your review).
## 2. Row 16: internal development bootstrap (#1510)
Filbert's R1 approval (receipt 26204) pins nine files. Researcher's live check
is receipt 26216.
| Path | Pin match | Ready |
|---|---|---|
| agents/researcher/SOUL.md | R1 pin `aeb422bc` | yes |
| agents/researcher/CONTEXT.md (new) | R1 pin `caa71d37` | yes |
| agents/researcher/README.md (new) | R1 pin `e1b494f6` | yes |
| agents/researcher/launch.sh (new) | R1 pin `825b4547` | yes |
| agents/researcher/validate-sessions.mjs (new) | R1 pin `0420f136` | yes |
| scripts/test-darkwing-launch.mjs | R1 pin `d79d4611` | yes |
| docs/plans/2026-09-13_internal-development-bootstrap.md (new) | R1 pin `6ef48e9a` | yes |
| AGENTS.md | R1 pin `1f2ca52e` no longer matches | shared, see section 5 |
| agents/README.md | R1 pin `089b1c2b` no longer matches | shared, see section 5 |
### agents/sage launch files
`agents/sage/{launch.sh,README.md,SOUL.md,validate-sessions.mjs,CONTEXT.md}`
are not in the row 16 R1 pin set. SESSIONS.md (2026-09-09 01:34 UTC) records
that Codex added them on Jason's request ("Owner-corrected Sage location"). I
found no review receipt for them in my records. Row 16 covers five agents:
Darkwing, Dewey, Filbert, Rocko and Researcher. `agents/sage/CONTEXT.md` was
last written today at 14:30 local, after Jason's lead ruling, which suggests
Sage edited it. I list these files but do not claim them. They need a review
or Jason's word before they are committed. Your call.
## 3. docs/TOOLS.md (#1511)
The whole working diff against HEAD is the one-line `--by` usage flag. Its
changed lines are identical to the approved patch
`agents/darkwing/work/task-attribution/tools-usage.patch` (SHA-256
`86bcba3c…`, Filbert approved in #1511 R2). Ready, with #1511.
## 4. Briefs and evidence records
| Path | Row | Ready |
|---|---|---|
| docs/plans/2026-09-13_board-attention-status.md | 22 | yes, brief |
| docs/plans/2026-09-14_discord-board-row.md | 18 | yes, brief |
| docs/plans/2026-09-14_task-attribution.md | #1511 | yes, brief |
| docs/plans/2026-09-15_relaunch-activity.md | #1512 | yes, brief |
| agents/darkwing/work/board-attention/* (3 files) | 22 | yes |
| agents/darkwing/work/discord-board/* (8 files) | 18 | yes |
| agents/darkwing/work/internal-development-bootstrap/* (2 files) | 16 | yes |
| agents/darkwing/work/task-attribution/* (11 files) | #1511 | yes |
| agents/darkwing/work/relaunch-activity/* (8 files, including today's re-run record) | #1512 | yes |
| agents/darkwing/work/tree-manifest-2026-09-26.md (this file) | — | after Sage's review |
I scanned the evidence records and briefs for credential shapes (GitHub,
GitLab, Slack, OpenAI-style keys, bearer tokens, private keys, Discord bot
tokens). No hits. They do hold PIDs, loopback ports and `/tmp` paths, which
are the evidence.
### Older coordination records, 2026-09-08 to 09-10
29 untracked files under `docs/plans/reviews/`:
`2026-09-08_publication-trial-*` (21, #1497), `2026-09-08_skill-launcher-*`
(2, #1498), `2026-09-09_bma-foundation-pointer-handoff.md`, and
`2026-09-10_m20-*` (9, #1499/#1500). These are my review requests, snapshots
and coordination notes from before the native relaunch. None are in the
published sets `29c1defe` (#1497) or `3daee5ad` (#1500), and I can't find the
reason they were excluded. The credential scan found nothing. Not ready until
I've read them for anything that was kept out on purpose.
## 5. Shared files, both of us
| Path | Mine | Hazard |
|---|---|---|
| AGENTS.md | row 16 roster, row 22 attention convention | Sage's lead ruling edits today |
| agents/README.md | row 16 roster | Sage's edits today |
| docs/plans/QUEUE.md | rows 1, 6, 16, 18, 22 (row 5 is Dewey's) | Sage replaced my coordinator line; don't revert |
| docs/plans/CURRENT.md | row 6, 18, 22 narrative | Sage's 09-26 entry |
| docs/plans/DEFERRED.md | #1512 engine intermittency, row 18 concurrent hangs, Rocko usage notice | the #1512 entry needs the re-run outcome appended |
| docs/SESSIONS.md | my session lines | append-only, mixed authors |
| BUILD-LOG.md | entries for rows 16, 18, 22, #1511, #1512; Dewey's #1507 CHAT entries are also uncommitted | see below |
**BUILD-LOG.md would lose committed history if committed from the working
copy.** The working file is missing 119 lines that are in HEAD. They are the
three phase entries committed with rows 23, 24 and 25 (`1685deb4`, `1949ed8d`,
`43d7574d`: 36, 45 and 53 added lines). The row 23–25 commits seem to have been
built from HEAD plus their own hunks, leaving the working file behind. Staging
the working BUILD-LOG.md as it is would delete those entries, which breaks the
append-only rule. The fix is to rebuild the working file as HEAD plus the
uncommitted entries, in order. Nothing is lost. The same check on SESSIONS.md
and DEFERRED.md found zero HEAD lines missing. CURRENT.md (6), QUEUE.md (10),
AGENTS.md (4), agents/README.md (3) and TOOLS.md (1) show small differences
that look like intentional row edits. Check them before committing.
+34
View File
@@ -0,0 +1,34 @@
# Researcher native development context
You are Researcher, a member of the repository-native Mosaic Stack development
team coordinated by Darkwing. Work in `/mnt/storage/src/mosaic-stack`.
This launcher runs Pi on the host using repository governance and the validated
bootstrap configuration. It is not a managed fleet seat or sandbox. Host tools
are available for authorized research, evidence and fixture work; their presence
never grants access to unrelated work, secrets or live services.
Read `docs/plans/QUEUE.md` and the applicable owner-approved task before acting.
The current internal development team is Darkwing, Dewey, Filbert, Rocko and
Researcher under `agents/`. Route development coordination to Darkwing. Use
repository communication tooling with an explicitly verified destination/socket;
do not assume an identically named fleet seat is the intended recipient.
Do not modify `~/.mosaic` launchers, agents, provisioning or other state for this
bootstrap assignment. Do not start or stop live fleet processes, migrate sessions,
change policy or widen access. Work on repository source and offline evidence.
Use your performing-agent identity for authorized issue/Git actions, never an
implicit operator identity. Missing access is a boundary, not a fallback license.
Distinguish cited sources, hypotheses, synthetic checks and observed behavior.
Preserve other authors' files. Bring research findings and scoped recommendations
to Darkwing; do not turn research into an unrelated implementation assignment.
The installed goal extension owns any operator-set goal lifecycle. Follow its
actual tool schema and use ms-proactive-agent for authorized continuation, not a
second goal loop. This launcher starts no goal or assignment. Conversation state
is per-agent under the checkout's `.pi/state/researcher`; normal launch resumes
validated history and `--fresh` preserves prior conversations.
Context is captured at launch. Source changes do not update an existing process's
injected instructions; no automatic restart is authorized by editing this file.
+33
View File
@@ -0,0 +1,33 @@
# Researcher
Repository-native technical research seat, coordinated by Darkwing. Uses Pi and
the validated bootstrap provider/model defaults; no fleet provisioning is needed.
From `/mnt/storage/src/mosaic-stack`:
```sh
agents/researcher/launch.sh --check
agents/researcher/launch.sh
agents/researcher/launch.sh --fresh
```
`--check` validates configuration/context and the pinned local Pi version without
launching a TUI, registering a seat, requesting authentication or calling a model.
Normal launch registers the repository seat through `scripts/mosaic`, then uses
the existing host-development helper. It resumes validated per-agent conversation
state under `.pi/state/researcher`; `--fresh` starts another conversation without
deleting history. Existing launcher locking prevents duplicate native instances.
This is the same explicit native mode as Darkwing/Dewey/Filbert, not a fallback
from a container policy refusal. The retained `agent.json` describes the separate
managed-mode definition; no managed role or capability policy is changed here.
Bootstrap configuration remains authoritative. Missing config, context or pinned
dependencies fails closed. No configuration or credentials are copied from a fleet
seat. No files under `~/.mosaic` are modified by this source setup. Use the existing
bootstrap/build workflow documented in the repository, rather than installing an
unversioned engine or modifying a home-directory launcher.
No engine was launched to verify this source change. Offline launcher tests use a
fake Pi and isolated registration/session directories. Live startup and external
provider authentication are separate observations, not implied by passing tests.
+6 -3
View File
@@ -1,5 +1,8 @@
# SOUL - researcher
You are the researcher seat of the Mosaic fleet. You are curious, methodical,
and precise. You cite what you know, admit what you do not, and never guess
when you can verify.
You are Researcher, the technical researcher on Mosaic Stack's internal
repository-based development team. Darkwing coordinates assignments and review.
You are curious, methodical and precise. Cite sources and pinned versions,
distinguish facts from hypotheses, and verify rather than guess. Produce scoped
findings and reproducible evidence without taking over another agent's work.
Follow the current repository assignment, not an assumed fleet mission.
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Researcher's repository-native development entry point.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
if [ -z "${MOSAIC_LAUNCH_REGISTERED:-}" ] && ! printf '%s\n' "$@" | grep -qx -- '--check'; then
exec "$REPO/scripts/mosaic" launch --repo "$REPO" --harness pi researcher -- "$@"
fi
exec "$REPO/scripts/agent.sh" --host-dev researcher "$@"
+21
View File
@@ -0,0 +1,21 @@
// Refuse damaged history before Pi's --continue can silently skip it.
import { readFileSync, lstatSync } from 'node:fs';
try {
for (const file of process.argv.slice(2)) {
if (!lstatSync(file).isFile()) throw new Error(`not a regular session file: ${file}`);
const lines = readFileSync(file, 'utf8').trim().split('\n');
const entries = lines.map((line) => JSON.parse(line));
const header = entries[0];
if (header?.type !== 'session' || typeof header.id !== 'string' || !header.id ||
typeof header.version !== 'number' || typeof header.cwd !== 'string' ||
!Number.isFinite(Date.parse(header.timestamp)) ||
entries.slice(1).some((entry) => !entry || typeof entry.type !== 'string')) {
throw new Error(`invalid session structure: ${file}`);
}
if (header.cwd !== process.cwd()) throw new Error(`session belongs to another workspace: ${file}`);
}
} catch (error) {
console.error(`researcher: cannot safely resume: ${error.message}; inspect history or explicitly use --fresh`);
process.exit(1);
}
+1 -1
View File
@@ -134,7 +134,7 @@ Push is never automatic.
```bash
scripts/mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]
scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
scripts/mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]
```
Runs a seat's `launch.sh` unchanged (arguments after `--` pass through) and
@@ -0,0 +1,42 @@
# Board attention status correction
Jason accepted discovery/reply tests 1-4, then reported Researcher still showing
waiting after its completed reply. He approved separating idle from explicit
requests for human input. This is a #1503 acceptance defect, not new chat scope.
Darkwing authors; internal Filbert independently reviews. Baseline observed on
refactor: d9745a451004699256af32c2145df40df9f5b4a5. Preserve existing shared edits
and Dewey's UI work. Source scope: control-board scanner, tests and README,
AGENTS.md status guidance, the downstream packages/webui/tests/fixture.mjs waiting
case and scoped tracking records. Dewey confirmed no overlapping ownership. No home-directory or
fleet changes, agent restarts, new public JSON fields, approval/control authority,
text classification service or unrelated UI changes.
## Rule and evidence
Completed normal assistant replies are idle. A completed assistant reply requests
human attention only when its first nonblank text line is exactly prefixed
`Input needed: ` followed by a nonempty request. This readable status convention
is explicit, not a guess based on questions, sentiment, or turn completion.
Quoted examples, user/tool messages and thinking blocks do not signal attention.
Tool activity and errors retain their existing precedence. The latest message
wins; a later ordinary completed reply clears the previous request.
The signal is advisory display state, never permission to perform an action.
Agents use it only for Jason's decision/input, not a wait on another agent or a
routine completion. Existing conversations without the signal become idle after
normal completion. Earlier unstated human blockers cannot be inferred safely.
Seen acknowledges a specific waiting/error event. It hides that event from the
attention list but does not resolve a genuine input request. New explicit requests
reappear; ordinary completions do not. No change to existing API enum or fields.
Acceptance: reproduce BOARD_REPLY_OK incorrectly becoming waiting, test completed
replies/explicit requests/quoted and stale markers/tool activity/errors/Seen,
run scanner/server and relevant downstream regressions, and obtain exact-candidate
independent review. Verify the corrected classification against Researcher's
actual session without editing it. Running-board replacement and operator retest
must be reported separately from source tests; no unattended service restart.
Before implementation: new attention tests reproduce two failures, including
BOARD_REPLY_OK being classified as waiting. One precedence test already passes.
@@ -0,0 +1,60 @@
# Repository-native development bootstrap
Tracking: #1510, created and read-back verified as Darkwing. Branch `refactor`.
Observed shared HEAD after initial checks: `dc5902aafded4042b6a919d9a6aec9f78febe935`.
Other owners have dirty shared docs and active work; they are not adopted here.
Owner-authorized source work. Jason directs Darkwing to coordinate development
through Darkwing, Dewey, Filbert, Rocko and Researcher in the canonical checkout,
`/mnt/storage/src/mosaic-stack`. No `~/.mosaic` launcher/provisioning changes.
No live fleet stop or migration. This assignment supersedes fleet expansion as
the current development priority, not the retained foundation's worker policies.
## Delivery boundary
Darkwing authors; Filbert independently reviews. Keep other agents' existing
changes and completed records, including Dewey's UI work and Sage's DYOR scope.
Allowed source work is the internal team documentation and Researcher's missing
native entry/context/recovery files, the existing native launcher regression
suite, and scoped queue/current/session/build tracking. Preserve agent.json and
roles/contracts; explicit host mode does not widen a managed worker's authority.
No new engine installation, credentials, home-directory launcher, native process
launch, fleet task dispatch, deployment or unrelated runtime change.
Researcher uses the validated configured Pi model, the smallest consistent
choice rather than a new provider/model decision. Darkwing/Dewey defaults,
Filbert's pinned Codex configuration and Rocko's Sonnet wrapper are unchanged.
## Acceptance
1. All five internal agents have repository launch entry points and current
coordination guidance. Researcher has the same per-agent validation and
recovery behavior as the existing native Pi seats.
2. Offline fake-engine tests cover context, registration, first/fresh/resume,
malformed history, missing configuration/resources and duplicate-instance
refusal. All data and registrations stay in test fixtures.
3. Real launcher `--check` succeeds without a TUI, authentication/model request,
seat registration or home-directory writes. This is configuration evidence,
not actual engine/provider interoperability.
4. No managed role/config/contract changes, no edits to `~/.mosaic`, and no
migration or interruption of live seats. Existing unrelated diffs remain.
5. Independent exact-candidate review before claiming source readiness. No
deployment or unapproved publication follows automatically from this task.
## Current source checkpoint
Researcher previously had only a managed agent.json and fleet-labelled SOUL.
Added native launcher, CONTEXT, README and the existing session validation
behavior; changed its persona to the internal research role. Added Researcher to
the existing isolated Pi launcher suite. All four Pi seats and Rocko's offline
suite pass, six tests total. No real engine launched.
All five real launcher `--check` calls pass with no TUI, authentication request,
model call or seat registration. Next: obtain internal Filbert's independent
exact-candidate review. New launches read the
updated source context; existing running sessions do not reload it implicitly.
The approved CHAT-01C local commit and its Git receive-pack authorization gate
remain separate. Do not use this assignment to modify external provisioning or
publish unrelated local commits.
@@ -0,0 +1,65 @@
# Discord connector board row
Current authorized queue item: row 18, #1509, pilot plan section 11. Jason
assigned this to Darkwing and now explicitly requires automatic continuation to
the next authorized item after each accepted iteration. This starts row 18,
not unrelated gated work. Preserve the accepted row-22 attention correction.
## Ownership and scope
Filbert implements in the canonical checkout on refactor. Darkwing independently
reviews the exact candidate; Dewey reviews any visible presentation change.
Single writer for this implementation: Filbert. Existing dirty files remain
owned by their authors and must not be reset, adopted or overwritten.
Allowed implementation paths: packages/control-board source/tests/README and
minimal packages/webui source/tests changes needed to display the connector
and refuse replies. No connector source, bindings, secrets, launchers, systemd
units, live service changes or files under ~/.mosaic. No commits or publication
by the implementer. Darkwing owns shared tracking records.
## Existing contract to implement
The original accepted connector brief supplies the interface:
- Discover one row per <dataRoot>/discord/<binding>.json, with 0600 regular
non-symlink files. Project fleet, agent `<seat> (discord: <binding>)`.
Validate only safe name/seat identity for discovery; never dereference or
expose token paths, Discord/user/channel IDs, or the rest of the binding.
- Sessions are under <dataRoot>/sessions/discord-<binding>.
- Reuse the connector's read-only readPid/ownerState identity checks from
packages/discord/src/journal.mjs. A positive live PID plus matching start
tick and boot ID is necessary. Missing, corrupt, dead or unverifiable owners
cannot be reported live. Do not signal, recover, unlock or rewrite anything.
- Show STOP presence as braked without interpreting its private contents.
Liveness and braking must be distinguishable. Existing completed-reply idle
semantics still apply to session activity.
- Refuse board replies server-side for connector rows even if a stale or
forged registration claims a tmux destination. The UI must not offer reply.
- Avoid filesystem traversal, symlink reads and disclosure of private binding
fields. A malformed binding must not manufacture an actionable row. Report
discovery failures safely rather than dumping private content or credentials.
Daily counters are optional in the original brief and excluded from this first
row. No new ingress, controller, Discord API calls or engine/model calls.
## Acceptance and handoff
Implement and test discovery/privacy, positive and negative process identity,
STOP/braked display, ordinary session state, server-side reply refusal and
unchanged ordinary-agent replies. Test both board and WebUI integration with
isolated fixtures. Preserve the accepted attention regression coverage.
Return an exact frozen candidate, changed paths and reproducible test results.
The author does not self-approve. Darkwing and Dewey return scoped independent
verdicts before integration. Source tests do not prove live service transitions.
A read-only observation of the running connector is allowed after source review;
no live service stop/brake/restart or backend replacement is inferred. Existing
protected-operation and operator-acceptance gates remain in force.
## Continuation correction
Darkwing previously said he was continuing to this item but performed no action
before ending the turn. Jason called out the violation. This entry records the
actual start and ownership; a promise or dispatch is not completion. While the
author works, Darkwing prepares independent acceptance and reconciles records.
+45
View File
@@ -0,0 +1,45 @@
# Task attribution, row 6 code phase
Jason accepted row 18 and has explicitly required automatic continuation after
accepted iterations. This starts the next already-briefed item, row 6 of QUEUE,
under the existing control-board MVP plan's Piece 5. The newer standing start
instruction replaces that piece's historical Sunday start-message scheduling;
its ordering and protected-action gates remain. Use repository-native agents,
not fleet launchers. Rows 9-13 still wait for row 6 completion.
## Source assignment
Rocko implements the code phase from the internal agents/rocko profile in the
canonical checkout, refactor. Filbert independently reviews backend/schema/tests;
Dewey independently reviews presentation. Darkwing coordinates and owns tracking.
The existing Rocko native process uses the older tmux socket; this is not a
managed fleet worker assignment or authority to change its launcher.
Implement the already-approved taskSetBy requirement: mosaic seat task records
an explicit --by NAME, otherwise MOSAIC_AGENT_NAME, otherwise unknown. Board
serialization and both current board presentations display it by the task source.
This is caller-supplied attribution, never authenticated identity or authorization.
Scope: packages/seat source/tests/docs, packages/control-board source/tests/docs,
and minimal WebUI presentation/tests. Preserve existing attention and Discord-row
changes exactly except the necessary additive integration. No launcher, connector,
role, credential, home, live registration or service changes; no commits/push by
the author. Use isolated fixtures, not actual seat task mutation during development.
Acceptance:
- Explicit --by wins over environment; absent/empty environment gives unknown.
Invalid explicit input refuses before writing. Values are bounded and escaped.
- Existing registration records without taskSetBy still load, showing unknown.
No breaking registration version change or implicit migration of old files.
- Preserve unrelated registration fields and original startedAt on task updates.
- Display attribution only for the task actually selected: no stale registration
attribution on a transcript-derived task, and no borrowing native attribution
for a Discord connector's fixed task. Attribution never enables replies/access.
- Isolated CLI/schema/scanner/browser regressions, exact frozen handoff and
independent review. Existing accepted behavior remains covered.
This code phase precedes the plan's specified first Filbert behavior assignment,
Relaunched seat shows the old last message. Do not start that concurrently.
Gate F and its human-message evidence are not claimed by this code phase; record
actual future dates, not the old plan's anticipated 2026-09-13 measurement.
No live backend replacement or publication follows automatically from source tests.
@@ -0,0 +1,35 @@
# Relaunch activity, row 6 behavior assignment
Jason confirmed the #1511 table tag and inspector wording. The code phase's
bounded local delivery is accepted. Under his standing continuation mandate,
start Piece 5's already-specified first Filbert assignment from the control-board
MVP plan: Relaunched seat shows the old last message.
Filbert is sole source author. Darkwing independently reviews backend and tests;
Dewey reviews presentation. Work in the canonical refactor checkout. Preserve
accepted attention, Discord and task-attribution changes and all other owners'
changes. Frozen prior candidates remain unchanged.
Scope: minimal packages/control-board scanner/presentation/tests/docs changes
and corresponding WebUI presentation/tests when needed. If a live matching
registration's startedAt is newer than recorded session activity, show
'relaunched at X, no messages since' instead of presenting an old message as
current activity. Preserve historical transcript data. After new session activity,
normal presentation resumes. Stale/dead registrations must not assert a relaunch.
No effect on connector owner identity or fixed connector task.
Prove the old behavior first with isolated fixtures. Cover newer launch vs old
activity, equality, subsequent new activity, stale registration and unknown/missing
activity. Preserve attention/Seen, task attribution and reply authorization.
Do not silently redesign those semantics. Return an exact frozen candidate,
changed/inherited pins and reproducible tests for both independent reviewers.
No launcher, Claude discovery, credential, role, home, live registration or
service changes by the author. No commits/push or shared tracking edits by the
author. No real seat relaunch is needed for the source fixture tests. Darkwing
owns tracking and any separately authorized integration.
Publication remains a separate gate, including prior unpublished dependencies.
Do not claim Gate F or issue closure from fixture tests. Its eventual evidence
must use the actual assignment dates and demonstrate no direct Jason-to-Filbert
work messages; never backdate the old plan's anticipated 2026-09-13 metric.
+88 -4
View File
@@ -12,10 +12,10 @@ rewritable; they are not run records and are not evidence.
| State | Plain-words meaning |
|---------|----------------------|
| working | The agent is in the middle of a turn: thinking or running a tool. A seat whose newest log entry is a tool call or a tool result is working even if its last words looked like a question. |
| waiting | The agent finished its turn with a text-only message. It is your move now. |
| waiting | The agent explicitly requested your input in its completed reply. |
| error | The agent's last turn ended in an error, was aborted, or was cut off. Go look at it. |
| offline | There is no live tmux session for this agent right now, or its session exists but no longer runs `pi`. |
| idle | The agent is live but has not had a conversation yet. |
| idle | The agent is live and available, including after a normal completed reply. |
| unknown | The scanner could not ask tmux (missing or not answering). It does not assume the agent is alive. |
Liveness means a pane in the agent's tmux session is actually running `pi`
@@ -23,6 +23,79 @@ Liveness means a pane in the agent's tmux session is actually running `pi`
exists but only runs bash or some other program counts as offline, not
waiting.
### Explicit human attention
A completed assistant reply requests attention only when its first nonblank text
line begins at column zero with `Input needed: ` and a nonempty request. Example:
```text
Input needed: Choose staging or production for the approved test.
```
Ordinary replies such as `BOARD_REPLY_OK`, completion reports and questions without
this explicit signal are idle. Code/quote examples and thinking blocks do not
count. Tool activity/errors retain precedence. A later ordinary completed reply
clears the previous request; a user/tool message is working, not waiting.
Agents reserve the signal for Jason's decision or input, not another agent's
review or routine completion. It is display state, never action authorization.
Existing unmarked replies cannot establish a human blocker. Seen acknowledges
the current event and removes it from the attention list; it does not resolve a
genuine request or change waiting to idle. A new request reappears.
## Relaunch activity notice
A live native row with a positively live, matching registration gets a
`relaunchedAt` timestamp when that registration's valid `startedAt` is strictly
newer than valid recorded `lastActivity`. Both board presentations then show
`relaunched at X, no messages since` in the current activity/preview positions.
The inspector explicitly labels retained last activity, assistant text and errors
as historical. CLI `scan --print` also replaces its old preview/age with the notice. New recorded session activity at or after the launch timestamp
clears the notice. Equality does not assert a relaunch.
Missing/invalid timestamps, missing or mismatched registrations, unknown/dead PID
or row liveness, and connector rows yield `relaunchedAt: null`. Unknown is not
proof of relaunch. The comparison uses the existing session activity timestamp,
not a new transcript index or authenticated process-incarnation protocol.
This field changes presentation only. Historical transcript files and serialized
lastActivity/lastAssistantText/lastError remain intact. State, attention, Seen,
task selection/attribution and reply eligibility are unchanged. An old unresolved
waiting/error state therefore remains visible, with its text labelled historical,
rather than being silently cleared by the new notice. No launcher or live
registration mutation is required to test this behavior.
## Discord connector rows
The CLI discovers private `<dataRoot>/discord/<binding>.json` files on every
scan, including server rescans. Only matching safe binding name and seat identity
are used. Files must be regular, non-symlink, mode 0600 and at most 1 MiB.
Discovery never resolves token paths or projects binding policy, Discord IDs or
user/channel lists. Invalid bindings produce fixed, content-free discoveryErrors.
Library callers enable this with `discordDataRoot` on scan/startServer.
Rows use project `fleet` and agent `<seat> (discord: <binding>)`, distinct from
native seats. Task is fixed `Discord connector`, with source `connector`, never
inferred from the first user message: Discord routing envelopes contain private
IDs. Ordinary-agent task derivation and assistant transcript display are unchanged.
Session history comes from `sessions/discord-<binding>` without
following linked directories/files. Liveness uses the connector's readPid and
ownerState checks, not tmux or registration: only a live PID with matching boot
ID and start tick is live. Missing, invalid, dead or unverifiable owners are
non-live. The `connector` projection contains binding, alive, ownerState and
braked only; it does not expose the journal directory or owner record.
STOP presence is shown separately as braked, even when the owner is offline.
An unsafe journal path gives brake unknown, not an unbraked claim. STOP contents
are never read. Activity retains the usual idle/waiting/error rules. Both board
pages omit Reply for connector rows; the backend refuses connector replies before
transport even if a stale/forged native registration supplies tmux details.
This is read-only observation, not connector control. No brake, unlock, recovery,
Discord request, counter collection or engine action is performed. Tests use
isolated bindings, sessions and process identities; they do not prove live
service transitions or authorize replacing a running board.
## Commands
```
@@ -156,8 +229,18 @@ name alone. A registered task, project or workspace replaces the derived
value and the row's source field (`taskSource`, `activeProjectSource`,
`workspaceSource`) reads `registration`; the page shows a small source tag
next to the value and a "Registered" line in the detail with the start time,
harness, pid and tmux session. An empty task or a null project or workspace
in the record leaves the derived value in place. Rows with no registration
harness, pid and tmux session. When the task shown is the registered one,
the row also carries `taskSetBy` (#1511): the record's `taskSetBy` from
`mosaic seat task --by NAME` (else `$MOSAIC_AGENT_NAME`, else `unknown`),
or `unknown` for a record written before the field existed. Both pages show
it as a "set by NAME" tag after the task's source tag and a "Task set by"
detail row. It is what the caller claimed, not a verified identity: the
board escapes and displays it and nothing else reads it; in particular the
reply gate looks only at the registration. For every other task source
(`first-user-message`, `connector`, a stale registration, no task) it is
null, so a record never lends its setter to a task it did not set. An empty
task or a null project or workspace in the record leaves the derived value
in place. Rows with no registration
are exactly as before. The board only reads `seats/`; `mosaic launch` and
`mosaic seat task` are the only writers. A malformed record is listed in
`registrationErrors` on the index (and on stderr for `scan`) and skipped.
@@ -194,6 +277,7 @@ values), the scan refuses rather than silently dropping every mark.
"cwd": "/mnt/storage/src/mosaic-stack",
"task": "Read agents/darkwing/work/RESTART.md",
"taskSource": "first-user-message",
"taskSetBy": null,
"workspace": "/mnt/storage/src/mosaic-stack",
"workspaceSource": "tmux-pane",
"activeProject": "mosaic-stack",
+6 -4
View File
@@ -42,7 +42,7 @@ async function main() {
const boardDir = join(dataRoot, "board");
const seatsDir = join(dataRoot, "seats");
if (opts.command === "serve") {
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir });
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
const addr = server.address();
process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`);
const stop = () => server.close(() => process.exit(0));
@@ -50,13 +50,15 @@ async function main() {
process.on("SIGTERM", stop);
return;
}
const index = scan(specs, { boardDir, isAlive, seatsDir });
const index = scan(specs, { boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
for (const line of index.discoveryErrors) process.stderr.write(`${line}\n`);
for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`);
if (opts.print) {
for (const s of index.sessions) {
const flag = s.waitingOnYou ? "*" : s.seen ? "s" : " ";
const age = s.ageSeconds == null ? "-" : `${Math.round(s.ageSeconds / 60)}m`;
process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : ""}\n`);
const age = s.relaunchedAt ? "relaunch" : s.ageSeconds == null ? "-" : `${Math.round(s.ageSeconds / 60)}m`;
const preview = s.relaunchedAt ? `relaunched at ${s.relaunchedAt}, no messages since` : s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : "";
process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${preview}\n`);
}
}
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you, ${index.seen.length} seen, ${index.registered.length} registered, ${index.registrationStale.length} stale)\n`);
+73
View File
@@ -0,0 +1,73 @@
// Read-only connector discovery. Never load binding policy or token resources.
import { lstatSync, readdirSync, openSync, closeSync, fstatSync, readFileSync, constants } from 'node:fs';
import { join, resolve, parse } from 'node:path';
import { readPid, ownerState } from '../../discord/src/journal.mjs';
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
// Check every existing ancestor, not just the final component.
export function noLinks(path) {
const absolute = resolve(path);
let current = parse(absolute).root;
for (const part of absolute.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part);
try { if (lstatSync(current).isSymbolicLink()) return false; }
catch (e) { if (e.code === 'ENOENT') return true; return false; }
}
return true;
}
function stat(path) { try { return lstatSync(path); } catch { return null; } }
export function discoverDiscordAgents(dataRoot) {
const specs = [], errors = [];
const dir = join(dataRoot, 'discord');
if (!noLinks(dir)) return { specs, errors: ['discord discovery: unsafe directory'] };
const ds = stat(dir);
if (!ds) return { specs, errors };
if (!ds.isDirectory()) return { specs, errors: ['discord discovery: invalid directory'] };
let names;
try { names = readdirSync(dir).sort(); } catch { return { specs, errors: ['discord discovery: unreadable directory'] }; }
for (const file of names.filter(n => n.endsWith('.json'))) {
let fd;
try {
const name = file.slice(0, -5);
if (!NAME.test(name)) throw new Error();
fd = openSync(join(dir, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const s = fstatSync(fd);
if (!s.isFile() || (s.mode & 0o7777) !== 0o600 || s.size > 1024 * 1024) throw new Error();
const raw = JSON.parse(readFileSync(fd, 'utf8'));
if (!raw || raw.name !== name || typeof raw.seat !== 'string' || !NAME.test(raw.seat)) throw new Error();
// Do not retain the binding object, even in internal scan specs.
specs.push({ agent: `${raw.seat} (discord: ${name})`, project: 'fleet', sessionsDir: join(dataRoot, 'sessions', `discord-${name}`), tmux: null, connector: { binding: name, journalDir: join(dir, name) } });
} catch { errors.push('discord discovery: skipped invalid binding'); }
finally { if (fd !== undefined) closeSync(fd); }
}
return { specs, errors };
}
export function inspectDiscord(connector, { identity } = {}) {
const dir = connector.journalDir, owner = join(dir, 'run.lock', 'owner.json');
let braked = null;
if (noLinks(dir)) {
try { lstatSync(join(dir, 'STOP')); braked = true; }
catch (e) { if (e.code === 'ENOENT') braked = false; }
}
let ownerStatus = 'invalid';
if (noLinks(owner)) {
const s = stat(owner);
if (!s || s.isFile()) {
try { ownerStatus = ownerState(readPid(dir), identity ? { identity } : {}); } catch { ownerStatus = 'invalid'; }
}
}
return { binding: connector.binding, braked, ownerState: ownerStatus, alive: ownerStatus === 'live' };
}
// Connector session trees must not follow links, including ancestor links.
export function newestDiscordSession(dir) {
if (!noLinks(dir)) return null;
let best = null;
const walk = path => {
const s = stat(path);
if (!s || s.isSymbolicLink()) return;
if (s.isDirectory()) { for (const name of readdirSync(path)) walk(join(path, name)); }
else if (s.isFile() && path.endsWith('.jsonl') && (!best || s.mtimeMs > best.time)) best = { path, time: s.mtimeMs };
};
walk(dir);
return best?.path ?? null;
}
+21 -7
View File
@@ -63,6 +63,7 @@
.seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle}
.msg-error{color:var(--danger)}
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.relaunch-notice{display:block;max-width:36ch;overflow-wrap:anywhere;white-space:normal}
.model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap}
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.unknown{color:var(--muted);font-style:italic}
@@ -71,6 +72,7 @@
.reply-receipt{display:block;margin-top:.3em;font-size:.85em}
.reply-ok{color:var(--muted)}
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.set-by{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.detail-row td{background:var(--raised)}
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
.detail-list dt{color:var(--muted);font-family:var(--font)}
@@ -194,6 +196,12 @@
function sourceLabel(source) { return SOURCE_LABEL[source] || source; }
function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; }
// Who set a registered task (#1511): shown only when the task on the row is
// the registered one (the scan gives null otherwise). It is what the
// caller claimed when it ran `mosaic seat task`, not a verified identity,
// and it never changes what the row lets you do.
function setByTag(rec) { return rec.taskSetBy ? '<span class="set-by" title="set by (as claimed by the caller, not verified): ' + esc(rec.taskSetBy) + '">set by ' + esc(rec.taskSetBy) + "</span>" : ""; }
function setByText(rec) { return rec.taskSetBy ? esc(rec.taskSetBy) + " (as claimed by the caller, not verified)" : "—"; }
// The model a seat is running, e.g. "gpt-6-astra" with the provider in the
// hover title. modelText() already escapes, so the title takes it as is.
// Empty when the log has not named one yet.
@@ -205,6 +213,7 @@
var replyReceipts = {};
var replyDrafts = {};
function canReply(rec) {
if (rec.connector) return false;
var reg = rec.registered;
return !!(reg && reg.alive !== false && reg.tmux && reg.tmux.session);
}
@@ -216,6 +225,7 @@
}
function replyControl(rec) {
var id = [rec.project, rec.agent].join("/");
if (rec.connector) return '<span class="unknown">Board replies disabled for Discord connectors</span>';
if (!canReply(rec)) return '<span class="unknown">reply needs a registered seat</span>' + replyReceipt(id);
return '<form class="reply-form" data-id="' + esc(id) + '">' +
'<input type="text" class="reply-text" maxlength="2000" autocomplete="off" aria-label="Reply to ' + esc(rec.agent) + '" placeholder="Reply to ' + esc(rec.agent) + '" value="' + esc(replyDrafts[id] || "") + '">' +
@@ -233,12 +243,14 @@
return parts.join("; ");
}
function relaunchNotice(rec) { return rec.relaunchedAt ? 'relaunched at ' + esc(rec.relaunchedAt) + ', no messages since' : ''; }
function buildRowPair(rec, showProject) {
var idx = rowIdx++;
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
var open = !!openDetails[key];
var cls = "session-row" + (rec.state === "offline" ? " is-offline" : "");
var msg = rec.state === "error" && rec.lastError
var notice = relaunchNotice(rec);
var msg = notice ? '<span class="relaunch-notice">' + notice + '</span>' : rec.state === "error" && rec.lastError
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
@@ -246,7 +258,7 @@
// from the log and tmux, else "unknown". Never guessed. The source tag on
// the row says which one is shown.
var task = rec.task
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource)
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource) + setByTag(rec)
: '<span class="unknown">unknown</span>';
var activeProject = rec.activeProject
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
@@ -255,8 +267,8 @@
'<tr class="' + cls + '">' +
projectCell +
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button>" + modelTag(rec) + "</td>" +
"<td>" + badge(rec.state) + seenControl(rec) + "</td>" +
"<td>" + esc(humanAge(rec.ageSeconds)) + "</td>" +
"<td>" + badge(rec.state) + (rec.connector ? '<span class="unknown"> · ' + (rec.connector.braked === true ? 'braked (STOP)' : rec.connector.braked === false ? 'not braked' : 'brake unknown') + ' · owner ' + esc(rec.connector.ownerState) + '</span>' : '') + seenControl(rec) + "</td>" +
"<td>" + (notice ? '<span class="relaunch-notice">' + notice + '</span>' : esc(humanAge(rec.ageSeconds))) + "</td>" +
"<td>" + task + "</td>" +
"<td>" + activeProject + "</td>" +
"<td>" + msg + "</td>" +
@@ -271,6 +283,7 @@
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
"<dt>Task set by</dt><dd>" + setByText(rec) + "</dd>" +
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
@@ -278,11 +291,12 @@
"<dt>Reply</dt><dd>" + replyControl(rec) + "</dd>" +
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
"<dt>Last activity</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
(notice ? "<dt>Current activity</dt><dd>" + notice + "</dd>" : "") +
"<dt>" + (notice ? "Historical last activity" : "Last activity") + "</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
"<dt>Last message</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
"<dt>Last error</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
"<dt>" + (notice ? "Historical last message" : "Last message") + "</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
"<dt>" + (notice ? "Historical last error" : "Last error") + "</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
"</dl></td></tr>";
return main + detail;
}
+49 -16
View File
@@ -3,10 +3,10 @@
//
// States (plain words):
// working - the agent is in the middle of a turn (thinking or running tools)
// waiting - the agent finished its turn; it is your move
// waiting - the agent explicitly requests human input in its completed reply
// error - the agent's last turn ended in an error, was aborted, or was cut off; look at it
// offline - no tmux session for this agent, or its session no longer runs pi
// idle - the agent is live but has no conversation yet
// idle - the agent is available, with no explicit input request
// unknown - liveness could not be checked (tmux missing or unresponsive); not a guess
//
// Board files are derived and rewritable. They are not run records. The one
@@ -19,6 +19,8 @@ import { homedir } from "node:os";
import { spawnSync } from "node:child_process";
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs';
export const STATES = Object.freeze(["working", "waiting", "error", "offline", "idle", "unknown"]);
const TEXT_LIMIT = 240;
@@ -137,12 +139,20 @@ export function hasToolCall(message) {
return Array.isArray(message?.content) && message.content.some((c) => c && c.type === "toolCall");
}
// Pure state rule. alive: true/false, or null when liveness could not be checked.
// A null check is reported as "unknown" rather than assumed alive (fail closed).
// Acceptance rule (plan page, 2026-09-12): a seat mid-tool-call is working,
// never waiting. The newest entry being an assistant message with a tool call,
// or a tool result, means working even if the last text looked like a question.
// waiting needs a text-only assistant message whose turn ended (stopReason stop).
// Explicit display convention, not a language classifier or control permission.
// Only a plain first nonblank text line can request human input. Quoted/code
// examples and thinking blocks must not create attention events.
function requestsHumanInput(message) {
if (!Array.isArray(message.content)) return false;
const text = message.content.filter((c) => c?.type === "text" && typeof c.text === "string")
.map((c) => c.text).join("\n");
const first = text.split(/\r?\n/).find((line) => line.trim()) ?? "";
const prefix = "Input needed: ";
return first.startsWith(prefix) && first.slice(prefix.length).trim().length > 0;
}
// A missing liveness check stays unknown. Offline/errors/tool activity take
// precedence over attention text. Completion alone means idle, not waiting.
export function deriveState({ alive, session }) {
if (alive === false) return "offline";
if (alive !== true) return "unknown";
@@ -151,7 +161,7 @@ export function deriveState({ alive, session }) {
if (m.role === "assistant") {
if (m.stopReason === "error" || m.stopReason === "aborted" || m.stopReason === "length") return "error";
if (hasToolCall(m)) return "working";
if (m.stopReason === "stop") return "waiting";
if (m.stopReason === "stop") return requestsHumanInput(m) ? "waiting" : "idle";
return "working";
}
return "working";
@@ -317,9 +327,10 @@ export function pidAlive(pid) {
// it is still reported under `registered` (with alive false) but the derived
// values win, because the record describes a launch that is not running.
export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {}, registration = null, isPidAlive = pidAlive } = {}) {
const live = liveness(isAlive(spec.tmux));
const connector = spec.connector ? inspectDiscord(spec.connector) : null;
const live = connector ? { alive: connector.alive, workspace: null } : liveness(isAlive(spec.tmux));
const alive = live.alive;
const file = findNewestSession(spec.sessionsDir);
const file = connector ? newestDiscordSession(spec.sessionsDir) : findNewestSession(spec.sessionsDir);
const session = file ? readSession(file) : null;
const state = deriveState({ alive, session });
const scannedAt = now();
@@ -328,8 +339,16 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
const needsYou = state === "waiting" || state === "error";
const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity;
const cwd = session?.cwd ?? null;
const record = registration && typeof registration === "object" ? registration : null;
const record = !connector && registration && typeof registration === "object" ? registration : null;
const registeredAlive = record ? isPidAlive(record.pid) : null;
// Presentation only: retain history and attention/Seen semantics. Unknown
// liveness, unmatched records and unknown timestamps cannot assert relaunch.
const launchTime = typeof record?.startedAt === "string" ? Date.parse(record.startedAt) : NaN;
const activityTime = typeof lastActivity === "string" ? Date.parse(lastActivity) : NaN;
const relaunchedAt = alive === true && registeredAlive === true &&
samePath(record?.sessionsDir, spec.sessionsDir) && Number.isFinite(launchTime) &&
Number.isFinite(activityTime) && launchTime > activityTime
? new Date(launchTime).toISOString() : null;
const reg = record && registeredAlive !== false ? record : null;
const derivedWorkspace = live.workspace ?? cwd;
const workspace = reg?.workspace ?? derivedWorkspace;
@@ -339,11 +358,21 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
const activeProject = reg?.project ?? derivedProject;
const activeProjectSource = reg?.project ? "registration" : derivedProject ? "workspace-git-root" : null;
const firstUserText = session?.firstUserText ?? null;
const task = reg?.task ? reg.task : firstUserText;
const taskSource = reg?.task ? "registration" : firstUserText ? "first-user-message" : null;
// Discord user text starts with a routing envelope containing private IDs.
// Connector task metadata is fixed, never inferred from that transcript.
const task = connector ? "Discord connector" : reg?.task ? reg.task : firstUserText;
const taskSource = connector ? "connector" : reg?.task ? "registration" : firstUserText ? "first-user-message" : null;
// Who set the task (#1511), only when the task shown *is* the registered
// one: a record written before the field existed reads "unknown"; a
// transcript-derived task, a stale registration and a connector's fixed
// task carry no attribution at all (null), so a record can never lend its
// setter to a task it did not set. It is the caller's claim, bounded by
// the seat package on read, and it grants nothing.
const taskSetBy = taskSource === "registration" ? (reg.taskSetBy ?? "unknown") : null;
return {
agent: spec.agent,
project: spec.project,
...(connector ? { connector } : {}),
state,
waitingOnYou: needsYou && !isSeen,
seen: isSeen,
@@ -359,6 +388,7 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
provider: session?.provider ?? null,
task,
taskSource,
taskSetBy,
workspace,
workspaceSource,
activeProject,
@@ -367,6 +397,7 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
? { startedAt: record.startedAt, updatedAt: record.updatedAt, harness: record.harness, pid: record.pid, alive: registeredAlive, tmux: record.tmux, layout: record.layout, launchScript: record.launchScript }
: null,
lastActivity,
relaunchedAt,
ageSeconds,
lastAssistantText: session?.lastAssistantText ?? null,
lastError: session?.lastError ?? null,
@@ -403,12 +434,13 @@ function writeAtomic(path, data) {
// Scan every spec and write <boardDir>/sessions/<project>/<agent>.json plus index.json.
// seatsDir (optional): where `mosaic launch` registrations live; read only.
export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAlive } = {}) {
export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAlive, discordDataRoot = null } = {}) {
if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path");
if (seatsDir !== null && (typeof seatsDir !== "string" || !isAbsolute(seatsDir))) throw new ConfigError("seatsDir must be an absolute path or null");
const seen = loadSeen(boardDir);
const { registrations, errors: registrationErrors } = loadRegistrations(seatsDir);
const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations), isPidAlive }));
const discord = discordDataRoot ? discoverDiscordAgents(discordDataRoot) : { specs: [], errors: [] };
const records = [...specs, ...discord.specs].map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations), isPidAlive }));
for (const rec of records) {
const dir = join(boardDir, "sessions", rec.project);
mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -423,6 +455,7 @@ export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAliv
registered: records.filter((r) => r.registered).map(seenKey),
registrationStale: records.filter((r) => r.registered && r.registered.alive === false).map(seenKey),
registrationErrors,
discoveryErrors: discord.errors,
sessions: records,
};
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
+3 -2
View File
@@ -53,6 +53,7 @@ export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, e
if (text.length > REPLY_LIMIT) return { status: 400, body: { error: `text is longer than ${REPLY_LIMIT} characters` } };
const rec = index.sessions.find((r) => seenKey(r) === key);
if (!rec) return { status: 404, body: { error: `unknown row: ${key}` } };
if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return { status: 409, body: { error: "board replies are disabled for Discord connectors" } };
const reg = rec.registered;
if (!reg) return { status: 409, body: { error: "reply needs a registered seat (start it through scripts/mosaic launch)" } };
if (reg.alive === false) return { status: 409, body: { error: `registration is stale: pid ${reg.pid} is gone` } };
@@ -130,8 +131,8 @@ export function loadPage(path = join(import.meta.dirname, "page.html")) {
}
// specs: agent specs to scan on each request. boardDir: where scan writes.
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) {
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive });
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) {
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot });
return createHttpServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if (req.method === "POST" && url.pathname === "/api/reply") {
@@ -0,0 +1,55 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { scan, markSeen, deriveState } from '../src/scan.mjs';
test('explicit request, Seen, ordinary completion and a new request have distinct attention states', () => {
const root = mkdtempSync(join(tmpdir(), 'board-attention-flow-'));
try {
const sessionsDir = join(root, 'sessions');
mkdirSync(sessionsDir);
const file = join(sessionsDir, 's.jsonl');
writeFileSync(file, JSON.stringify({ type: 'session', id: 'fixture', cwd: root, timestamp: '2026-09-13T00:00:00Z' }) + '\n');
const specs = [{ agent: 'researcher', project: 'fixture', sessionsDir, tmux: {} }];
const boardDir = join(root, 'board');
const get = () => scan(specs, { boardDir, isAlive: () => true }).sessions[0];
const append = (second, text) => appendFileSync(file, JSON.stringify({
type: 'message', timestamp: `2026-09-13T00:00:0${second}Z`,
message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] },
}) + '\n');
append(1, 'Input needed: Choose A or B.');
let r = get();
assert.equal(r.state, 'waiting');
assert.equal(r.waitingOnYou, true);
markSeen(boardDir, { project: 'fixture', agent: 'researcher', lastActivity: r.lastActivity, seen: true });
r = get();
assert.equal(r.state, 'waiting', 'acknowledgment does not resolve an input request');
assert.equal(r.seen, true);
assert.equal(r.waitingOnYou, false);
append(2, 'BOARD_REPLY_OK');
r = get();
assert.equal(r.state, 'idle');
assert.equal(r.waitingOnYou, false);
assert.equal(r.seen, false, 'ordinary completion needs no Seen action');
append(3, 'Input needed: Confirm the revised choice.');
r = get();
assert.equal(r.state, 'waiting');
assert.equal(r.waitingOnYou, true, 'old Seen does not suppress a new request');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('attention convention ignores reasoning/quoted examples and permits leading blank lines', () => {
const derive = (content, alive = true) => deriveState({ alive, session: { lastMessage: { role: 'assistant', stopReason: 'stop', content } } });
const text = (value) => [{ type: 'text', text: value }];
for (const value of ['> Input needed: Choose A.', ' Input needed: Choose A.', '`Input needed: Choose A.`', 'Input needed:\tChoose A.']) {
assert.equal(derive(text(value)), 'idle', value);
}
assert.equal(derive([{ type: 'thinking', thinking: 'Input needed: Choose A.' }, ...text('Done.')]), 'idle');
assert.equal(derive(text('\n\r\nInput needed: Choose A.')), 'waiting');
assert.equal(derive(text('Input needed: Choose A.'), false), 'offline');
assert.equal(derive(text('Input needed: Choose A.'), null), 'unknown');
});
@@ -0,0 +1,29 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { deriveState } from '../src/scan.mjs';
const state = (text, overrides = {}) => deriveState({ alive: true, session: {
lastMessage: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }], ...overrides },
} });
test('completed smoke replies and ordinary questions are idle, not human blockers', () => {
for (const text of ['BOARD_REPLY_OK', 'RESEARCHER_NATIVE_SMOKE_OK', 'Done.', 'Shall I continue?']) {
assert.equal(state(text), 'idle', text);
}
});
test('only an explicit first-line input request makes a finished reply waiting', () => {
assert.equal(state('Input needed: Choose the release target.\nDetails follow.'), 'waiting');
for (const text of ['Input needed:', 'Input needed: ', 'Example:\nInput needed: Choose A.', '```\nInput needed: Choose A.\n```']) {
assert.equal(state(text), 'idle', text);
}
});
test('tool activity, user text, errors and unfinished turns override attention text', () => {
const text = 'Input needed: Choose A.';
assert.equal(state(text, { content: [{ type: 'text', text }, { type: 'toolCall', name: 'read' }] }), 'working');
assert.equal(state(text, { role: 'user' }), 'working');
assert.equal(state(text, { role: 'toolResult' }), 'working');
assert.equal(state(text, { stopReason: 'error' }), 'error');
assert.equal(state(text, { stopReason: undefined }), 'working');
});
@@ -0,0 +1,145 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, symlinkSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { discoverDiscordAgents, inspectDiscord } from '../src/discord.mjs';
import { scan, scanAgent } from '../src/scan.mjs';
import { replyToRow, startServer } from '../src/serve.mjs';
import { identityOf } from '../../discord/src/journal.mjs';
import { envelope } from '../../discord/src/context.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
test('STOP access failure is unknown, not absence, under a non-root identity', () => {
const result = spawnSync(process.execPath, ['--input-type=module', '-e', `
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { inspectDiscord } from ${JSON.stringify(new URL('../src/discord.mjs', import.meta.url).href)};
assert.notEqual(process.getuid(), 0, 'permission regression must not run as root');
const root = mkdtempSync(join(tmpdir(), 'board-stop-permission-'));
const dir = join(root, 'journal'), connector = { binding: 'fixture', journalDir: dir };
mkdirSync(dir);
try {
assert.equal(inspectDiscord(connector).braked, false, 'verified absence');
writeFileSync(join(dir, 'STOP'), 'private contents never read');
assert.equal(inspectDiscord(connector).braked, true, 'verified presence');
chmodSync(dir, 0o000);
const inaccessible = inspectDiscord(connector);
assert.equal(inaccessible.braked, null, 'inaccessible STOP is unknown');
assert.equal(inaccessible.alive, false);
chmodSync(dir, 0o700);
assert.equal(inspectDiscord(connector).braked, true, 'restored access sees STOP');
rmSync(join(dir, 'STOP'));
assert.equal(inspectDiscord(connector).braked, false, 'verified removal');
} finally { chmodSync(dir, 0o700); rmSync(root, { recursive: true, force: true }); }
`], { encoding: 'utf8', timeout: 10000, ...(process.getuid() === 0 ? { uid: 65534, gid: 65534 } : {}) });
assert.equal(result.status, 0, result.stdout + result.stderr);
});
test('connector Task never inherits Discord routing envelopes; ordinary Task still uses user text', () => {
const f = fixture();
try {
const text = envelope({ guildName: 'Synthetic guild', channelName: 'synthetic-channel', authorId: '111111111111111111', messageId: '222222222222222222', text: 'Synthetic user request' });
const sessionsDir = join(f.root, 'sessions', 'discord-demo');
writeFileSync(join(sessionsDir, 's.jsonl'), JSON.stringify({ type: 'message', message: { role: 'user', content: [{ type: 'text', text }] } }) + '\n');
const connector = scanAgent(discoverDiscordAgents(f.root).specs[0]);
assert.equal(connector.task, 'Discord connector');
assert.equal(connector.taskSource, 'connector');
for (const secret of ['111111111111111111', '222222222222222222', 'Synthetic user request']) assert.equal(connector.task.includes(secret), false);
const ordinary = scanAgent({ agent: 'native', project: 'repo', sessionsDir, tmux: {} }, { isAlive: () => true });
assert.equal(ordinary.task, text.replace(/\s+/g, ' ').trim());
assert.equal(ordinary.taskSource, 'first-user-message');
} finally { f.cleanup(); }
});
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'board-discord-'));
mkdirSync(join(root, 'discord', 'demo', 'run.lock'), { recursive: true });
writeFileSync(join(root, 'discord', 'demo.json'), JSON.stringify({ name: 'demo', seat: 'researcher', tokenFile: '/never-read-secret', guildId: 'PRIVATE-ID', users: ['PRIVATE-USER'] }), { mode: 0o600 });
const sessions = join(root, 'sessions', 'discord-demo'); mkdirSync(sessions, { recursive: true });
writeFileSync(join(sessions, 's.jsonl'), JSON.stringify({ type: 'message', timestamp: '2026-09-14T00:00:00Z', message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: 'done' }] } }) + '\n');
const owner = join(root, 'discord', 'demo', 'run.lock', 'owner.json');
writeFileSync(owner, JSON.stringify({ pid: process.pid, ...identityOf(process.pid) }));
return { root, owner, cleanup: () => rmSync(root, { recursive: true, force: true }) };
}
test('connector discovery keeps only safe identity; rejects modes, mismatches, links and traversal', () => {
const f = fixture();
try {
const result = discoverDiscordAgents(f.root);
assert.equal(result.specs.length, 1); assert.deepEqual(result.errors, []);
assert.equal(result.specs[0].agent, 'researcher (discord: demo)');
assert.equal(JSON.stringify(result).includes('PRIVATE'), false); assert.equal(JSON.stringify(result).includes('tokenFile'), false);
writeFileSync(join(f.root, 'discord', 'bad.json'), '{"name":"bad","seat":"../../escape","secret":"PRIVATE"}', { mode: 0o600 });
symlinkSync(join(f.root, 'discord', 'demo.json'), join(f.root, 'discord', 'linked.json'));
writeFileSync(join(f.root, 'discord', 'other.json'), '{"name":"wrong","seat":"x"}', { mode: 0o600 });
chmodSync(join(f.root, 'discord', 'demo.json'), 0o644);
const bad = discoverDiscordAgents(f.root); assert.equal(bad.specs.length, 0); assert.equal(bad.errors.length, 4);
assert.equal(JSON.stringify(bad).includes('PRIVATE'), false);
} finally { f.cleanup(); }
});
test('canonical owner identity and STOP are independent; no tmux fallback or forged registration', () => {
const f = fixture();
try {
const spec = discoverDiscordAgents(f.root).specs[0];
const row = () => scanAgent(spec, { isAlive: () => { throw Error('must not ask tmux'); }, registration: { pid: process.pid, task: 'forged', taskSetBy: 'forged-setter', tmux: { session: 'forged' } } });
assert.equal(row().alive, true); assert.equal(row().state, 'idle'); assert.equal(row().registered, null);
// #1511: the connector's fixed task borrows no attribution from a native registration.
assert.equal(row().task, 'Discord connector'); assert.equal(row().taskSource, 'connector'); assert.equal(row().taskSetBy, null);
assert.equal(JSON.stringify(row()).includes('forged-setter'), false);
writeFileSync(join(f.root, 'discord', 'demo', 'STOP'), 'PRIVATE BRAKE CONTENT');
assert.equal(row().connector.braked, true); assert.equal(row().state, 'idle');
assert.equal(JSON.stringify(row()).includes('PRIVATE'), false);
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: '1', boot: identityOf(process.pid).boot }) }).alive, false);
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: null, boot: null }) }).ownerState, 'unknown');
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: identityOf(process.pid).start, boot: '00000000-0000-0000-0000-000000000000' }) }).ownerState, 'mismatch');
for (const value of ['broken PRIVATE', JSON.stringify({ pid: process.pid }), JSON.stringify({ pid: 2147483647, ...identityOf(process.pid) })]) {
writeFileSync(f.owner, value); assert.equal(row().alive, false); assert.equal(row().state, 'offline');
}
rmSync(f.owner); assert.equal(row().connector.ownerState, 'absent');
symlinkSync('/never-read-secret', f.owner); assert.equal(row().alive, false);
} finally { f.cleanup(); }
});
test('connector reply refusal precedes forged live tmux registration; ordinary agent still sends', () => {
let calls = 0; const exec = () => { calls++; return { status: 0, stdout: '', stderr: '' }; };
const r = { agent: 'researcher (discord: demo)', project: 'fleet', connector: { binding: 'demo' }, registered: { alive: true, tmux: { session: 'forged' } } };
assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/' + r.agent, text: 'hello', exec }).status, 409);
delete r.connector;
assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/' + r.agent, text: 'hello', exec }).status, 409);
assert.equal(calls, 0);
r.agent = 'ordinary'; assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/ordinary', text: 'hello', exec }).status, 200); assert.equal(calls, 1);
});
test('server rescans connector discovery and refuses HTTP reply without transport', async () => {
const f = fixture(); let server;
try {
const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats');
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'forged', project: 'fleet', sessionsDir, seatDir: sessionsDir, launchScript: join(f.root, 'unused.sh'), layout: 'repo', defaultWorkspace: f.root }, task: 'forged', tmux: { session: 'forged', socket: null }, pid: process.pid }));
server = await startServer({ port: 0, specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
const base = `http://127.0.0.1:${server.address().port}`;
let response = await fetch(base + '/api/board'); const board = await response.json();
assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true);
assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged');
response = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/researcher (discord: demo)', text: 'hello' }) });
assert.equal(response.status, 409);
rmSync(join(f.root, 'discord', 'demo.json'));
assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
} finally { if (server) await new Promise(r => server.close(r)); f.cleanup(); }
});
test('connector session links and linked directories are not read', () => {
const f = fixture();
try {
const sessions = join(f.root, 'sessions', 'discord-demo'); rmSync(sessions, { recursive: true });
symlinkSync(join(f.root, 'discord'), sessions);
const result = scan([], { boardDir: join(f.root, 'board'), discordDataRoot: f.root });
assert.equal(result.sessions[0].sessionFile, null);
rmSync(join(f.root, 'discord'), { recursive: true }); symlinkSync('/never-read-secret', join(f.root, 'discord'));
assert.equal(discoverDiscordAgents(f.root).specs.length, 0);
} finally { f.cleanup(); }
});
@@ -0,0 +1,85 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, renameSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { scanAgent } from '../src/scan.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { identityOf } from '../../discord/src/journal.mjs';
const oldAt = '2026-09-14T10:00:00.000Z';
const launchAt = '2026-09-15T10:00:00.000Z';
const newAt = '2026-09-15T10:01:00.000Z';
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'board-relaunch-'));
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
const file = join(sessionsDir, 'session.jsonl');
const message = (at, text) => JSON.stringify({ type: 'message', timestamp: at, message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] } }) + '\n';
writeFileSync(file, message(oldAt, 'Input needed: Historical request.'));
const spec = { agent: 'fixture', project: 'repo', sessionsDir, tmux: {} };
const registration = { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Registered task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' };
const read = (extra = {}) => scanAgent(spec, { isAlive: () => true, isPidAlive: () => true, registration, now: () => new Date(newAt), ...extra });
return { root, file, spec, registration, read, message, close: () => rmSync(root, { recursive: true, force: true }) };
}
test('newer live matching launch marks old activity, preserves history/attention/attribution, then clears on new activity', () => {
const f = fixture();
try {
const original = readFileSync(f.file, 'utf8');
const row = f.read();
assert.equal(row.relaunchedAt, launchAt);
assert.equal(row.lastActivity, oldAt); assert.equal(row.lastAssistantText, 'Input needed: Historical request.');
assert.equal(row.state, 'waiting'); assert.equal(row.waitingOnYou, true);
assert.equal(row.taskSetBy, 'fixture-setter'); assert.equal(row.taskSource, 'registration');
assert.equal(f.read({ seen: { 'repo/fixture': oldAt } }).seen, true);
assert.equal(f.read({ seen: { 'repo/fixture': oldAt } }).waitingOnYou, false);
assert.equal(readFileSync(f.file, 'utf8'), original, 'scanning never rewrites historical messages');
appendFileSync(f.file, f.message(newAt, 'Fresh completion.'));
const next = f.read(); assert.equal(next.relaunchedAt, null); assert.equal(next.state, 'idle');
assert.equal(next.lastAssistantText, 'Fresh completion.'); assert.equal(next.lastActivity, newAt);
} finally { f.close(); }
});
test('CLI print uses the relaunch notice instead of old current preview', () => {
const f = fixture();
try {
const state = join(f.root, '.pi', 'state', 'fixture'); mkdirSync(state, { recursive: true });
const sessionsDir = join(state, 'sessions'); renameSync(f.spec.sessionsDir, sessionsDir);
const dataRoot = join(f.root, 'data');
writeRegistration(join(dataRoot, 'seats'), { ...f.registration, sessionsDir });
const config = join(f.root, 'config.json'); writeFileSync(config, JSON.stringify({ dataRoot }));
const run = spawnSync(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'scan', '--config', config, '--repo', f.root, '--fleet', 'none', '--liveness', 'assume-alive', '--print'], { encoding: 'utf8', timeout: 10000 });
assert.equal(run.status, 0, run.stderr);
assert.ok(run.stdout.includes(`relaunched at ${launchAt}, no messages since`));
assert.equal(run.stdout.includes('Historical request.'), false);
} finally { f.close(); }
});
test('connector owner and fixed task never inherit a native relaunch notice', () => {
const f = fixture();
try {
const journalDir = join(f.root, 'journal'); mkdirSync(join(journalDir, 'run.lock'), { recursive: true });
writeFileSync(join(journalDir, 'run.lock', 'owner.json'), JSON.stringify({ pid: process.pid, ...identityOf(process.pid) }));
const row = scanAgent({ ...f.spec, connector: { binding: 'fixture', journalDir } }, { registration: f.registration, isAlive: () => { throw Error('connector cannot use native liveness'); } });
assert.equal(row.alive, true); assert.equal(row.relaunchedAt, null); assert.equal(row.task, 'Discord connector');
assert.equal(row.taskSetBy, null); assert.equal(row.registered, null);
} finally { f.close(); }
});
test('equality, stale/unknown/offline, mismatched registration and unknown activity do not assert relaunch', () => {
const f = fixture();
try {
assert.equal(f.read({ registration: { ...f.registration, startedAt: oldAt } }).relaunchedAt, null);
for (const value of [false, null]) assert.equal(f.read({ isPidAlive: () => value }).relaunchedAt, null);
for (const value of [false, null]) assert.equal(f.read({ isAlive: () => value }).relaunchedAt, null);
assert.equal(f.read({ registration: null }).relaunchedAt, null);
assert.equal(f.read({ registration: { ...f.registration, sessionsDir: join(f.root, 'different') } }).relaunchedAt, null);
assert.equal(f.read({ registration: { ...f.registration, startedAt: 'invalid' } }).relaunchedAt, null);
writeFileSync(f.file, f.message('invalid', 'Undated history.'));
assert.equal(f.read().relaunchedAt, null);
writeFileSync(f.file, JSON.stringify({ type: 'message', message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: 'No timestamp.' }] } }) + '\n');
assert.equal(f.read().relaunchedAt, null);
rmSync(f.file); assert.equal(f.read().relaunchedAt, null);
} finally { f.close(); }
});
+95 -11
View File
@@ -284,13 +284,13 @@ test("deriveState: full state table", () => {
{ name: "alive=false is offline regardless of session", input: { alive: false, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "offline" },
{ name: "no session is idle", input: { alive: true, session: null }, expected: "idle" },
{ name: "no lastMessage is idle", input: { alive: true, session: { lastMessage: null } }, expected: "idle" },
{ name: "assistant+stop is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "waiting" },
{ name: "assistant+stop is idle", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "idle" },
{ name: "assistant+error is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error" } } }, expected: "error" },
{ name: "assistant+aborted is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "aborted" } } }, expected: "error" },
{ name: "assistant+toolUse is working", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "toolUse" } } }, expected: "working" },
{ name: "assistant with a toolCall block is working even if stopReason says stop", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Shall I?" }, TOOL_CALL] } } }, expected: "working" },
{ name: "assistant+error with a toolCall block is still error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error", content: [TOOL_CALL] } } }, expected: "error" },
{ name: "assistant+stop with thinking and text (no tool call) is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "thinking", thinking: "hm" }, { type: "text", text: "done" }] } } }, expected: "waiting" },
{ name: "assistant+stop with thinking and text (no tool call) is idle", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "thinking", thinking: "hm" }, { type: "text", text: "done" }] } } }, expected: "idle" },
{ name: "user last is working", input: { alive: true, session: { lastMessage: { role: "user" } } }, expected: "working" },
{ name: "toolResult last is working", input: { alive: true, session: { lastMessage: { role: "toolResult" } } }, expected: "working" },
{ name: "assistant+length (cut off) is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "length" } } }, expected: "error" },
@@ -335,7 +335,7 @@ test("rule: newest entry is a tool result with no assistant text after it is wor
assert.equal(rec.waitingOnYou, false);
});
test("rule: a finished turn (text-only assistant message, stopReason stop) is waiting", () => {
test("rule: a finished ordinary turn is idle, even if it says your move", () => {
const root = makeRoot();
writeSessionFile(join(root, "sessions"), "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
@@ -344,8 +344,8 @@ test("rule: a finished turn (text-only assistant message, stopReason stop) is wa
messageLine({ timestamp: "2026-09-12T14:00:04Z", role: "assistant", stopReason: "stop", texts: ["Done. Your move."] }),
]);
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: () => new Date("2026-09-12T14:00:10Z") });
assert.equal(rec.state, "waiting");
assert.equal(rec.waitingOnYou, true);
assert.equal(rec.state, "idle");
assert.equal(rec.waitingOnYou, false);
});
// ---------------------------------------------------------------------------
@@ -764,7 +764,7 @@ test("scanAgent: waitingOnYou is true for waiting/error and false otherwise", ()
mkdirSync(errorDir, { recursive: true });
writeSessionFile(waitingDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
]);
writeSessionFile(workingDir, "s.jsonl", [
sessionLine({ id: "s2", timestamp: "2026-09-01T00:00:00Z", cwd: "/k" }),
@@ -866,7 +866,7 @@ test("scan: writes per-agent files and index.json, rerun overwrites, no leftover
sessionPath,
[
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
].join("\n") + "\n"
);
@@ -938,7 +938,7 @@ test("CLI: scan with assume-alive liveness exits 0, prints board summary, writes
join(sessionsDir, "s.jsonl"),
[
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
].join("\n") + "\n"
);
@@ -1131,7 +1131,7 @@ test("scanAgent: a seen mark matching the waiting session's lastTimestamp clears
mkdirSync(sessionsDir, { recursive: true });
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
]);
const seen = { "p/a": "2026-09-01T00:00:01Z" };
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
@@ -1146,7 +1146,7 @@ test("scanAgent: a stale mark (agent wrote something newer) is not seen and wait
mkdirSync(sessionsDir, { recursive: true });
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
]);
const seen = { "p/a": "2026-09-01T00:00:00Z" }; // stale: older than lastTimestamp
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
@@ -1197,7 +1197,7 @@ test("scan: index.seen and waitingOnYou reflect seen.json, which scan never rewr
const lastActivity = "2026-09-01T00:00:01Z";
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
]);
mkdirSync(boardDir, { recursive: true });
const seenPath = join(boardDir, "seen.json");
@@ -1219,3 +1219,87 @@ test("scan: a corrupt seen.json makes scan throw ConfigError (fail closed)", ()
writeFile(join(boardDir, "seen.json"), "{ bad json");
assert.throws(() => scan([], { boardDir, isAlive: () => true }), ConfigError);
});
// ---------------------------------------------------------------------------
// Task attribution (#1511): taskSetBy on the row, tied to the task source.
// ---------------------------------------------------------------------------
function attributionFixture(root, { taskSetBy, task = "registered task", pid = process.pid } = {}) {
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/derived/cwd" }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["derived task"] }),
]);
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
const base = makeRegistration({
resolved: { seat: "a", project: "rp", sessionsDir, seatDir: join(root, "seat-dir"), launchScript: join(root, "seat-dir", "launch.sh"), layout: "repo", defaultWorkspace: "/w" },
task, tmux: { socket: null, session: "a" }, pid, now: () => new Date("2026-09-12T13:00:00Z"),
});
const registration = taskSetBy === undefined ? base : { ...base, taskSetBy };
return { spec, sessionsDir, registration };
}
test("taskSetBy: a registered task carries the record's setter; a record without the field (pre-#1511) reads unknown; the value is not copied into registered", () => {
const named = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
const rec = scanAgent(named.spec, { isAlive: () => true, now: GATE_NOW, registration: named.registration });
assert.equal(rec.taskSource, "registration");
assert.equal(rec.taskSetBy, "darkwing");
assert.equal("taskSetBy" in rec.registered, false, "attribution lives on the row next to taskSource only");
const old = attributionFixture(makeRoot());
assert.equal("taskSetBy" in old.registration, false);
const oldRec = scanAgent(old.spec, { isAlive: () => true, now: GATE_NOW, registration: old.registration });
assert.equal(oldRec.taskSource, "registration");
assert.equal(oldRec.taskSetBy, "unknown");
const literal = attributionFixture(makeRoot(), { taskSetBy: "unknown" });
assert.equal(scanAgent(literal.spec, { isAlive: () => true, now: GATE_NOW, registration: literal.registration }).taskSetBy, "unknown");
});
test("taskSetBy: null whenever the task shown is not the registered one: no registration, an empty registered task, a stale registration; the field is always present", () => {
const none = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
const withoutReg = scanAgent(none.spec, { isAlive: () => true, now: GATE_NOW });
assert.equal(withoutReg.taskSource, "first-user-message");
assert.equal(withoutReg.taskSetBy, null);
assert.ok("taskSetBy" in withoutReg);
const empty = attributionFixture(makeRoot(), { taskSetBy: "darkwing", task: "" });
const emptyRec = scanAgent(empty.spec, { isAlive: () => true, now: GATE_NOW, registration: empty.registration });
assert.equal(emptyRec.task, "derived task");
assert.equal(emptyRec.taskSource, "first-user-message");
assert.equal(emptyRec.taskSetBy, null, "a setter on a record whose task is empty is not attached to the transcript's task");
assert.notEqual(emptyRec.registered, null);
const stale = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
const staleRec = scanAgent(stale.spec, { isAlive: () => true, now: GATE_NOW, registration: stale.registration, isPidAlive: () => false });
assert.equal(staleRec.task, "derived task");
assert.equal(staleRec.taskSource, "first-user-message");
assert.equal(staleRec.taskSetBy, null, "a stale registration lends neither its task nor its setter");
assert.equal(staleRec.registered.alive, false);
const noSession = scanAgent({ agent: "b", project: "p", sessionsDir: join(makeRoot(), "missing"), tmux: {} }, { isAlive: () => false, now: GATE_NOW });
assert.equal(noSession.task, null);
assert.equal(noSession.taskSetBy, null);
});
test("taskSetBy: scan() reads the field from disk through the seat package (bounded there), writes it to the per-agent record and index, and an invalid on-disk value is a registrationError, never a row value", () => {
const root = makeRoot();
const seatsDir = join(root, "seats");
const boardDir = join(root, "board");
const f = attributionFixture(root, { taskSetBy: "jason@host:1" });
writeRegistration(seatsDir, f.registration);
const index = scan([f.spec], { boardDir, seatsDir, isAlive: () => true, now: GATE_NOW });
assert.equal(index.sessions[0].taskSetBy, "jason@host:1");
assert.equal(JSON.parse(readFileSync(join(boardDir, "sessions", "p", "a.json"), "utf8")).taskSetBy, "jason@host:1");
assert.deepEqual(index.registrationErrors, []);
// A hand-edited record with markup in the field does not reach the board.
writeFileSync(join(seatsDir, "repo", "a", "registration.json"), JSON.stringify({ ...f.registration, taskSetBy: "<img src=x onerror=alert(1)>" }));
const rejected = scan([f.spec], { boardDir, seatsDir, isAlive: () => true, now: GATE_NOW });
assert.equal(rejected.sessions[0].taskSource, "first-user-message");
assert.equal(rejected.sessions[0].taskSetBy, null);
assert.equal(rejected.sessions[0].registered, null);
assert.equal(rejected.registrationErrors.length, 1);
assert.match(rejected.registrationErrors[0], /taskSetBy/);
assert.equal(JSON.stringify(rejected).includes("onerror"), false, "the offending value is not echoed anywhere on the index");
});
+65 -4
View File
@@ -176,8 +176,8 @@ test("startServer: serves page, healthz, and a rescanning /api/board", async ()
assert.equal(first.headers.get("content-type"), "application/json");
assert.equal(first.headers.get("cache-control"), "no-store");
const firstBody = await first.json();
assert.equal(firstBody.sessions[0].state, "waiting");
assert.ok(firstBody.waitingOnYou.includes("proj/agent1"));
assert.equal(firstBody.sessions[0].state, "idle");
assert.ok(!firstBody.waitingOnYou.includes("proj/agent1"));
assert.ok(existsSync(join(boardDir, "index.json")), "index.json must be written by the scan");
assert.ok(existsSync(join(boardDir, "sessions", "proj", "agent1.json")), "per-agent file must be written by the scan");
@@ -397,7 +397,7 @@ test("POST /api/seen marks a row; GET /api/board still shows it seen; seen:false
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
]);
const boardDir = join(root, "board");
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
@@ -557,7 +557,7 @@ test("CLI: scan --print marks a seen row with 's' and the summary line ends with
join(sessionsDir, "s.jsonl"),
[
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
].join("\n") + "\n"
);
const boardDir = join(dataRoot, "board");
@@ -881,3 +881,64 @@ test("page.html: the reply box appears only where canReply() holds (live registr
assert.match(html, /function withReplyFocus\(render\)/, "a refresh gives the caret back to the reply box");
assert.doesNotMatch(html, /send-keys/, "the page never talks tmux");
});
// ---------------------------------------------------------------------------
// Task attribution (#1511): serialized by the server, rendered by page.html.
// ---------------------------------------------------------------------------
test("startServer: /api/board carries taskSetBy from a live registration and null for the derived rows", async () => {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
const derivedDir = join(root, "derived-sessions");
for (const dir of [sessionsDir, derivedDir]) {
writeSessionFile(dir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "user", texts: ["derived task"] }),
]);
}
const seatsDir = join(root, "seats");
const reg = makeRegistration({
resolved: { seat: "agent1", project: "proj", sessionsDir, seatDir: join(root, "agent1"), launchScript: join(root, "agent1", "launch.sh"), layout: "repo", defaultWorkspace: null },
task: "registered task",
});
writeRegistration(seatsDir, { ...reg, taskSetBy: "darkwing" });
const specs = [
{ agent: "agent1", project: "proj", sessionsDir, tmux: {} },
{ agent: "agent2", project: "proj", sessionsDir: derivedDir, tmux: {} },
];
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir: join(root, "board"), seatsDir, isAlive: () => ({ alive: true, workspace: null }), page: "<html></html>" });
try {
const body = await (await fetch(`http://127.0.0.1:${server.address().port}/api/board`)).json();
const byAgent = Object.fromEntries(body.sessions.map((r) => [r.agent, r]));
assert.equal(byAgent.agent1.taskSource, "registration");
assert.equal(byAgent.agent1.taskSetBy, "darkwing");
assert.equal(byAgent.agent2.taskSource, "first-user-message");
assert.equal(byAgent.agent2.taskSetBy, null);
assert.deepEqual(body.registrationErrors, []);
} finally {
await new Promise((r) => server.close(r));
}
});
test("page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it", () => {
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
const lines = html.split("\n");
const rowPair = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/);
assert.ok(rowPair);
assert.match(rowPair[0], /sourceTag\(rec\.taskSource\) \+ setByTag\(rec\)/, "the set-by tag follows the task's source tag on the row");
assert.match(rowPair[0], /<dt>Task set by<\/dt><dd>" \+ setByText\(rec\) \+ "<\/dd>/, "the detail has a Task set by row");
const tagLine = lines.find((l) => l.includes("function setByTag(rec)"));
assert.ok(tagLine);
assert.match(tagLine, /rec\.taskSetBy \?/, "nothing is shown without a value");
assert.equal((tagLine.match(/esc\(rec\.taskSetBy\)/g) || []).length, 2, "the title and the text are both escaped");
assert.match(tagLine, /not verified/);
const textLine = lines.find((l) => l.includes("function setByText(rec)"));
assert.ok(textLine);
assert.match(textLine, /esc\(rec\.taskSetBy\)/);
assert.match(textLine, /not verified/);
// Attribution never enables a reply: canReply() reads the registration only.
const canReplyFn = html.match(/function canReply\(rec\) \{[\s\S]*?\n \}/);
assert.ok(canReplyFn);
assert.equal(canReplyFn[0].includes("taskSetBy"), false);
assert.equal(html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/)[0].includes("taskSetBy"), false);
});
+27 -3
View File
@@ -3,7 +3,7 @@
`mosaic launch <seat>` starts a seat through its existing launch script,
unchanged, and leaves one registration record that the control board reads
instead of guessing. `mosaic seat task <seat> <text>` changes the task on
that record. Nothing else. No provider or auth registry, no roster schema
that record and notes who set it. Nothing else. No provider or auth registry, no roster schema
change, no stopping or killing of seats, one record per seat.
Issue #1504. Plain ESM, no dependencies, Node 24 or newer.
@@ -17,7 +17,7 @@ or `seat` command; run it by path.
```
scripts/mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
[--harness NAME] [--repo PATH] [--config PATH] [-- args...]
scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
scripts/mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]
```
- `<seat>` is a name under `<repo>/agents/` (`--repo` defaults to the
@@ -36,6 +36,17 @@ scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config P
- The launch script receives `MOSAIC_LAUNCH_REGISTERED=<record path>`. A
launch script that sees this variable is already registered and must not
call `mosaic launch` again; `mosaic launch` refuses to run when it is set.
- `seat task` records who set the task in `taskSetBy` (#1511): `--by NAME`
if given, else `$MOSAIC_AGENT_NAME` if set and non-empty, else the word
`unknown`. An explicit value must be one token of 1 to 64 characters
(letters, digits, `.` `_` `@` `:` `-`, starting with a letter or digit);
anything else refuses with exit 4 before the record is touched. A set but
malformed `MOSAIC_AGENT_NAME` also refuses rather than being reported as
`unknown`; `--by` is the explicit way past it. The value is never echoed
back in a refusal. **This is what the caller said, not a verified
identity.** Nothing checks it, nothing grants on it; the board displays
it next to the task and does no more with it. `launch --task` does not
set the field.
The four repository seats (`agents/darkwing`, `agents/dewey`,
`agents/filbert`, `agents/rocko`) register themselves: their `launch.sh`
@@ -81,6 +92,10 @@ scan never changes it.
}
```
After `scripts/mosaic seat task darkwing "Row 6 (#1511)" --by jason` the
same record also carries `"taskSetBy": "jason"` and a non-null
`updatedAt`; `startedAt` and every other field are unchanged.
Fields asked for in the brief: `seat`, `project`, `task` (empty unless
`--task` or a later `seat task`), `workspace`, `tmux` (session name and
socket, from the `TMUX` variable of the pane the launch ran in; null outside
@@ -100,6 +115,11 @@ Fields added, and why:
the record is still written but the board cannot match it).
- `updatedAt`: set only by `seat task`, so a task change is distinguishable
from a relaunch.
- `taskSetBy`: set only by `seat task` (see above). Optional in the shape:
a record written before the field existed loads unchanged, the board shows
`unknown` for it, and nothing rewrites it until the next `seat task` or
launch. The record version stays 1; a value that is not one bounded token
makes the record unreadable (refused, not misread).
- `version`: so a later shape change can be refused rather than misread.
`project` and `workspace` are derived only for the repo layout (the
@@ -119,7 +139,11 @@ registration are unchanged. A malformed record is reported in
`registrationErrors` on the index and skipped; it never takes the board down
and it is never treated as absent silently. A record whose pid is no
longer running is stale: still shown on the Registered line, but it does
not override anything.
not override anything. The row's `taskSetBy` is the record's setter only
while the task shown is the registered one (`taskSource` `registration`);
otherwise it is null, so a stale or empty registration never attributes a
transcript-derived task, and a Discord connector's fixed task never borrows
a native record's setter.
The repository launch scripts are the only launchers that register.
Fleet seats under `~/.mosaic` stay on their own launchers (Jason's ruling,
+14 -7
View File
@@ -2,7 +2,11 @@
// Usage:
// mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
// [--harness NAME] [--repo PATH] [--config PATH] [-- args...]
// mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
// mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]
//
// `seat task` records who set the task as taskSetBy: `--by NAME`, else
// $MOSAIC_AGENT_NAME, else "unknown". This is what the caller claimed; it is
// not verified and it authorizes nothing.
//
// `launch` writes <dataRoot>/seats/<layout>/<seat>/registration.json, then replaces
// itself with the seat's launch.sh, unchanged, with everything after `--` as
@@ -15,12 +19,12 @@
import { rmSync } from "node:fs";
import {
SeatError, defaultConfigPath, loadDataRoot, seatsDir, resolveSeat, tmuxContext,
makeRegistration, writeRegistration, updateTask,
makeRegistration, writeRegistration, updateTask, resolveSetBy,
} from "./seat.mjs";
const USAGE = [
"usage: mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]",
" mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]",
" mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]",
].join("\n");
export const REGISTERED_ENV = "MOSAIC_LAUNCH_REGISTERED";
@@ -49,10 +53,10 @@ function parseLaunch(argv) {
}
function parseSeatTask(argv) {
const opts = { seat: null, task: null, layout: null, config: defaultConfigPath() };
const opts = { seat: null, task: null, layout: null, by: null, config: defaultConfigPath() };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--config" || a === "--layout") {
if (a === "--config" || a === "--layout" || a === "--by") {
if (i + 1 >= argv.length) throw new SeatError(`missing value for ${a}`, 4);
opts[a.slice(2)] = argv[++i];
} else if (a.startsWith("--")) throw new SeatError(`unknown argument: ${a}\n${USAGE}`, 4);
@@ -90,9 +94,12 @@ function launch(argv) {
function seatTask(argv) {
const opts = parseSeatTask(argv);
// Attribution is resolved (and an invalid value refused) before any read
// or write, so a refusal leaves the record exactly as it was.
const setBy = resolveSetBy({ by: opts.by, env: process.env });
const seats = seatsDir(loadDataRoot(opts.config));
const record = updateTask(seats, opts.seat, opts.task, { layout: opts.layout });
process.stdout.write(`mosaic seat task: ${opts.seat} (${record.layout} layout) task set (${opts.task.length} characters)\n`);
const record = updateTask(seats, opts.seat, opts.task, { layout: opts.layout, setBy });
process.stdout.write(`mosaic seat task: ${opts.seat} (${record.layout} layout) task set (${opts.task.length} characters, set by ${setBy})\n`);
return 0;
}
+38 -5
View File
@@ -23,6 +23,15 @@ import { spawnSync } from "node:child_process";
export const REGISTRATION_VERSION = 1;
export const SEAT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
export const TASK_LIMIT = 2000;
// Who set the task (#1511): caller-supplied attribution, bounded to one
// short token so it cannot carry markup or whitespace. The bound is a syntax
// limit, not a privacy filter: a numeric ID or an email-like name still fits,
// so callers choose what they put here. It is what the caller claimed, not
// an authenticated identity, and it grants nothing: the board displays it
// and does no more with it.
export const SET_BY_NAME = /^[A-Za-z0-9][A-Za-z0-9._@:-]{0,63}$/;
export const SET_BY_UNKNOWN = "unknown";
export const SET_BY_ENV = "MOSAIC_AGENT_NAME";
// exitCode follows docs/TOOLS.md: 1 operation failed, 2 invalid data or
// configuration, 4 usage.
@@ -121,6 +130,7 @@ export function tmuxContext({ env = process.env, exec = spawnSync } = {}) {
const FIELDS = Object.freeze([
"version", "seat", "project", "task", "workspace", "tmux", "harness",
"startedAt", "pid", "sessionsDir", "seatDir", "launchScript", "layout", "updatedAt",
"taskSetBy",
]);
const isNullableString = (v) => v === null || typeof v === "string";
@@ -134,6 +144,9 @@ export function validateRegistration(record) {
if (record.version !== REGISTRATION_VERSION) throw new SeatError("registration has an unsupported version");
if (typeof record.seat !== "string" || !SEAT_NAME.test(record.seat)) throw new SeatError("registration.seat is invalid");
if (typeof record.task !== "string" || record.task.length > TASK_LIMIT) throw new SeatError("registration.task must be a string");
// Optional: records written before #1511 have no taskSetBy and still load
// (the board shows "unknown"); no version change, no migration on read.
if (record.taskSetBy !== undefined && !(typeof record.taskSetBy === "string" && SET_BY_NAME.test(record.taskSetBy))) throw new SeatError("registration.taskSetBy is invalid");
for (const key of ["project", "workspace", "harness", "sessionsDir", "seatDir", "launchScript"]) {
if (!isNullableString(record[key])) throw new SeatError(`registration.${key} must be a string or null`);
}
@@ -208,17 +221,37 @@ export function findRegistrations(seats, seat) {
return LAYOUTS.map((layout) => readRegistration(seats, seat, layout)).filter(Boolean);
}
// Change the task field only. Refuses when the seat was never launched
// through `mosaic launch`, because there is nothing to attach the task to.
// A name that exists in more than one layout must be qualified with layout.
export function updateTask(seats, seat, task, { layout = null, now = () => new Date() } = {}) {
// Who a `seat task` is attributed to: an explicit `--by NAME`, else the
// MOSAIC_AGENT_NAME variable, else "unknown". An explicit value must match
// SET_BY_NAME; so must a non-empty environment value, because a malformed
// or unexpected value there is not the same as no value and is refused
// rather than silently reported as "unknown". Neither value is echoed back.
// The result is a claim by the caller, nothing more.
export function resolveSetBy({ by = null, env = process.env } = {}) {
if (by !== null && by !== undefined) {
if (typeof by !== "string" || !SET_BY_NAME.test(by)) throw new SeatError(`--by must be 1-64 characters of letters, digits, . _ @ : or -, starting with a letter or digit`, 4);
return by;
}
const fromEnv = env[SET_BY_ENV];
if (fromEnv === undefined || fromEnv === "") return SET_BY_UNKNOWN;
if (typeof fromEnv !== "string" || !SET_BY_NAME.test(fromEnv)) throw new SeatError(`${SET_BY_ENV} is set but is not a valid name (1-64 characters of letters, digits, . _ @ : or -); pass --by NAME or unset it`, 4);
return fromEnv;
}
// Change the task field, and record who set it (taskSetBy), and updatedAt.
// Every other field, startedAt included, is carried over unchanged. Refuses
// when the seat was never launched through `mosaic launch`, because there is
// nothing to attach the task to. A name that exists in more than one layout
// must be qualified with layout.
export function updateTask(seats, seat, task, { layout = null, now = () => new Date(), setBy = SET_BY_UNKNOWN } = {}) {
if (typeof task !== "string") throw new SeatError("task must be a string", 4);
if (task.length > TASK_LIMIT) throw new SeatError(`task is longer than ${TASK_LIMIT} characters`, 4);
if (typeof setBy !== "string" || !SET_BY_NAME.test(setBy)) throw new SeatError("setBy must be a valid name", 4);
const found = layout ? [readRegistration(seats, seat, layout)].filter(Boolean) : findRegistrations(seats, seat);
if (found.length === 0) throw new SeatError(`no registration for seat ${seat}; launch it through mosaic launch first`, 1);
if (found.length > 1) throw new SeatError(`seat ${seat} is registered in more than one layout (${found.map((r) => r.layout).join(", ")}); pass --layout`, 4);
const record = found[0];
const updated = { ...record, task, updatedAt: now().toISOString() };
const updated = { ...record, task, taskSetBy: setBy, updatedAt: now().toISOString() };
writeRegistration(seats, updated);
return updated;
}
+172 -1
View File
@@ -29,7 +29,10 @@ import {
updateTask,
findRegistrations,
samePath,
resolveSetBy,
TASK_LIMIT,
SET_BY_NAME,
SET_BY_UNKNOWN,
} from "../src/seat.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
@@ -91,6 +94,9 @@ function buildConfig(root, dataRoot = join(root, "data")) {
function cliEnv(overrides = {}) {
const env = { ...process.env, TMUX: "", TMUX_PANE: "" };
delete env.MOSAIC_LAUNCH_REGISTERED;
// The developer's shell may carry a seat name (agents/rocko/launch.sh
// exports one); attribution tests set it explicitly or not at all.
delete env.MOSAIC_AGENT_NAME;
return { ...env, ...overrides };
}
@@ -290,7 +296,7 @@ test("writeRegistration/readRegistration: round trip, permissions, absence, and
// 7. updateTask
// ---------------------------------------------------------------------------
test("updateTask: changes task and updatedAt only, and refuses appropriately", () => {
test("updateTask: changes task, taskSetBy and updatedAt only, and refuses appropriately", () => {
const root = makeRoot();
const seatDir = buildRepoSeat(root, "myseat");
const resolved = resolveSeat("myseat", { repo: root });
@@ -300,11 +306,14 @@ test("updateTask: changes task and updatedAt only, and refuses appropriately", (
const updated = updateTask(seats, "myseat", "revised");
assert.equal(updated.task, "revised");
assert.equal(updated.taskSetBy, SET_BY_UNKNOWN, "no setBy option records the literal unknown");
assert.notEqual(updated.updatedAt, null);
for (const key of Object.keys(record)) {
if (key === "task" || key === "updatedAt") continue;
assert.deepEqual(updated[key], record[key], `field ${key} changed unexpectedly`);
}
assert.equal(updated.startedAt, record.startedAt, "startedAt is the launch time and survives a task update");
assert.deepEqual(Object.keys(updated).sort(), [...Object.keys(record), "taskSetBy"].sort(), "exactly one field is added");
// Refuses when there is no registration to attach to.
assert.throws(() => updateTask(seats, "ghost", "x"), (err) => {
@@ -560,3 +569,165 @@ test("samePath: equal paths, symlinked dirs, distinct dirs, and non-strings", ()
assert.equal(samePath(1, "x"), false);
assert.equal(samePath(null, target), false);
});
// ---------------------------------------------------------------------------
// 14. Task attribution (#1511): taskSetBy on the record, resolveSetBy, and the
// --by / MOSAIC_AGENT_NAME precedence at the CLI. Attribution is a claim by
// the caller: bounded, escaped by the presentations, never authority.
// ---------------------------------------------------------------------------
test("resolveSetBy: explicit --by wins over the environment; absent or empty environment gives unknown; invalid explicit or environment values refuse with exit 4", () => {
assert.equal(resolveSetBy({ by: "darkwing", env: { MOSAIC_AGENT_NAME: "rocko" } }), "darkwing");
assert.equal(resolveSetBy({ by: null, env: { MOSAIC_AGENT_NAME: "rocko" } }), "rocko");
assert.equal(resolveSetBy({ env: {} }), SET_BY_UNKNOWN);
assert.equal(resolveSetBy({ env: { MOSAIC_AGENT_NAME: "" } }), SET_BY_UNKNOWN);
assert.equal(resolveSetBy({ by: "jason@host:1", env: {} }), "jason@host:1");
assert.equal(resolveSetBy({ by: "a".repeat(64), env: {} }), "a".repeat(64), "64 characters is the bound");
const refused = (fn, pattern) => assert.throws(fn, (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 4);
assert.match(err.message, pattern);
return true;
});
for (const bad of ["", " ", "a".repeat(65), "<b>x</b>", "two words", "-leading", ".leading", "tab\tname", "new\nline", "ünïcode", "a/b", "a;b", "$(x)"]) {
refused(() => resolveSetBy({ by: bad, env: { MOSAIC_AGENT_NAME: "valid" } }), /--by/);
assert.equal(SET_BY_NAME.test(bad), false);
// The offending value is never echoed back.
if (bad.trim().length > 1) assert.throws(() => resolveSetBy({ by: bad, env: {} }), (err) => !err.message.includes(bad));
}
refused(() => resolveSetBy({ by: 42, env: {} }), /--by/);
// A set but malformed environment value is refused, not read as unknown:
// silently reporting "unknown" would hide a misconfigured seat, and --by
// is the explicit way past it.
refused(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: "<script>" } }), /MOSAIC_AGENT_NAME/);
refused(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: "a".repeat(65) } }), /MOSAIC_AGENT_NAME/);
assert.throws(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: "<script>" } }), (err) => !err.message.includes("<script>"));
assert.equal(resolveSetBy({ by: "explicit", env: { MOSAIC_AGENT_NAME: "<script>" } }), "explicit", "an explicit value does not consult the environment at all");
});
test("validateRegistration/readRegistration: taskSetBy is optional; a record without it (written before #1511) still loads unchanged; an invalid one is refused; the version does not change", () => {
const root = makeRoot();
const seatDir = buildRepoSeat(root, "old");
const resolved = resolveSeat("old", { repo: root });
const seats = seatsDir(join(root, "data"));
const record = makeRegistration({ resolved, task: "launched task", pid: 1 });
assert.equal("taskSetBy" in record, false, "launch does not attribute; only seat task does");
assert.equal(record.version, 1);
// An old on-disk record, byte for byte, without the field.
const path = registrationPath(seats, "old", "repo");
mkdirSync(dirname(path), { recursive: true });
const oldBytes = JSON.stringify(record, null, 2) + "\n";
writeFileSync(path, oldBytes);
const loaded = readRegistration(seats, "old", "repo");
assert.deepEqual(loaded, record);
assert.equal(loaded.taskSetBy, undefined);
assert.equal(readFileSync(path, "utf8"), oldBytes, "reading never rewrites (no implicit migration)");
assert.equal(findRegistrations(seats, "old").length, 1);
// Valid values.
for (const ok of ["unknown", "darkwing", "jason@host:1", "a".repeat(64)]) validateRegistration({ ...record, taskSetBy: ok });
// Invalid values, never echoed.
for (const bad of [null, 7, "", "<b>", "two words", "a".repeat(65), {}]) {
assert.throws(() => validateRegistration({ ...record, taskSetBy: bad }), (err) => {
assert.ok(err instanceof SeatError);
assert.match(err.message, /taskSetBy/);
if (typeof bad === "string" && bad.length > 1) assert.equal(err.message.includes(bad), false);
return true;
});
}
writeFileSync(path, JSON.stringify({ ...record, taskSetBy: "<b>" }));
assert.throws(() => readRegistration(seats, "old", "repo"), /taskSetBy/);
});
test("updateTask: records setBy, preserves startedAt and every unrelated field, upgrades an old record in place only when the task is set, and replaces a previous attribution", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const resolved = resolveSeat("myseat", { repo: root });
const seats = seatsDir(join(root, "data"));
const started = new Date("2026-09-10T10:00:00Z");
const record = makeRegistration({ resolved, task: "original", harness: "pi", tmux: { socket: "mosaic-fleet", session: "myseat" }, pid: 1, workspace: "/w", project: "proj", now: () => started });
writeRegistration(seats, record);
const first = updateTask(seats, "myseat", "by darkwing", { setBy: "darkwing", now: () => new Date("2026-09-14T10:00:00Z") });
assert.equal(first.taskSetBy, "darkwing");
assert.equal(first.startedAt, started.toISOString());
assert.equal(first.updatedAt, "2026-09-14T10:00:00.000Z");
for (const key of ["seat", "project", "workspace", "tmux", "harness", "pid", "sessionsDir", "seatDir", "launchScript", "layout", "version"]) {
assert.deepEqual(first[key], record[key], `field ${key} changed unexpectedly`);
}
assert.deepEqual(readRegistration(seats, "myseat", "repo"), first);
const second = updateTask(seats, "myseat", "by jason", { setBy: "jason", now: () => new Date("2026-09-14T11:00:00Z") });
assert.equal(second.taskSetBy, "jason", "a later set replaces the attribution; it is not appended");
assert.equal(second.startedAt, started.toISOString());
const third = updateTask(seats, "myseat", "anonymous");
assert.equal(third.taskSetBy, SET_BY_UNKNOWN, "no setBy means unknown, never the previous name");
// Invalid setBy refuses with exit 4 before anything is written.
const before = readFileSync(registrationPath(seats, "myseat", "repo"), "utf8");
assert.throws(() => updateTask(seats, "myseat", "x", { setBy: "<b>" }), (err) => err instanceof SeatError && err.exitCode === 4);
assert.throws(() => updateTask(seats, "myseat", "x", { setBy: "" }), (err) => err instanceof SeatError && err.exitCode === 4);
assert.equal(readFileSync(registrationPath(seats, "myseat", "repo"), "utf8"), before);
});
test("CLI seat task: --by beats MOSAIC_AGENT_NAME, the environment beats nothing, empty environment is unknown, invalid --by or environment refuses with exit 4 and leaves the record byte for byte", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const { configPath, dataRoot } = buildConfig(root);
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
const launchResult = runCli(["launch", "myseat", "--task", "before", "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath, MOSAIC_AGENT_NAME: "launcher" }) });
assert.equal(launchResult.status, 0, launchResult.stderr);
const launched = JSON.parse(readFileSync(path, "utf8"));
assert.equal("taskSetBy" in launched, false, "launch --task does not attribute, even with the variable set");
const run = (args, env) => runCli(["seat", "task", "myseat", ...args, "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath, ...env }) });
const read = () => JSON.parse(readFileSync(path, "utf8"));
let r = run(["one", "--by", "darkwing"], { MOSAIC_AGENT_NAME: "rocko" });
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /set by darkwing/);
assert.equal(read().taskSetBy, "darkwing");
assert.equal(read().task, "one");
assert.equal(read().startedAt, launched.startedAt);
r = run(["two"], { MOSAIC_AGENT_NAME: "rocko" });
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /set by rocko/);
assert.equal(read().taskSetBy, "rocko");
r = run(["three"], {});
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /set by unknown/);
assert.equal(read().taskSetBy, "unknown");
r = run(["four"], { MOSAIC_AGENT_NAME: "" });
assert.equal(r.status, 0, r.stderr);
assert.equal(read().taskSetBy, "unknown");
for (const key of Object.keys(launched)) {
if (key === "task" || key === "updatedAt") continue;
assert.deepEqual(read()[key], launched[key], `field ${key} changed unexpectedly`);
}
const frozen = readFileSync(path, "utf8");
r = run(["five", "--by", "<script>alert(1)</script>"], { MOSAIC_AGENT_NAME: "rocko" });
assert.equal(r.status, 4);
assert.match(r.stderr, /refused: --by/);
assert.equal(r.stderr.includes("<script>"), false, "the refusal does not echo the value");
assert.equal(readFileSync(path, "utf8"), frozen, "an invalid --by writes nothing");
r = run(["five", "--by", ""], {});
assert.equal(r.status, 4);
assert.equal(readFileSync(path, "utf8"), frozen);
r = runCli(["seat", "task", "myseat", "five", "--config", configPath, "--by"], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath }) });
assert.equal(r.status, 4);
assert.match(r.stderr, /missing value for --by/);
assert.equal(readFileSync(path, "utf8"), frozen);
r = run(["five"], { MOSAIC_AGENT_NAME: "not a name" });
assert.equal(r.status, 4);
assert.match(r.stderr, /MOSAIC_AGENT_NAME/);
assert.equal(r.stderr.includes("not a name"), false);
assert.equal(readFileSync(path, "utf8"), frozen, "an invalid environment value writes nothing");
r = run(["six", "--by", "explicit"], { MOSAIC_AGENT_NAME: "not a name" });
assert.equal(r.status, 0, "an explicit --by is the way past a bad environment value");
assert.equal(read().taskSetBy, "explicit");
assert.equal(read().task, "six");
});
+11 -5
View File
@@ -8,8 +8,12 @@
let board = 'the configured board', palette = 'harbor', mode = 'light';
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
const source = value => value ? `<span class="source">${esc(labels[value] || value)}</span>` : '';
// Who set a registered task (#1511): null from the scan unless the task shown is the registered one. A caller's claim, not verified; it grants nothing.
const setBy = r => r.taskSetBy ? `<span class="source">set by ${esc(r.taskSetBy)}</span>` : '';
const badge = state => `<span class="badge ${ { working: 'badge-ok', waiting: 'badge-attn', error: 'badge-danger' }[state] || 'badge-muted'}">${esc(state || 'unknown')}</span>`;
const canReply = r => r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const connectorStatus = r => r.connector ? `<span class="source">${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'} · owner ${esc(r.connector.ownerState)}</span>` : '';
const relaunchNotice = r => r.relaunchedAt ? `relaunched at ${r.relaunchedAt}, no messages since` : '';
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const row = id => data?.sessions.find(r => key(r) === id);
const announce = text => { $('announce').textContent = text; };
function theme() {
@@ -33,11 +37,11 @@
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
function cards(rows) {
if (!rows.length) return '<p class="muted">Nothing here.</p>';
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}</p><p class="preview">${esc(r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(r.lastActivity || 'No activity')}</span>${seenButton(r)}</div></li>`).join('')}</ul>`;
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || r.lastActivity || 'No activity')}</span>${seenButton(r)}</div></li>`).join('')}</ul>`;
}
function table(rows) {
if (!rows.length) return '<p class="state">No sessions match these filters.</p>';
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${esc(r.lastActivity || 'unknown')}</td></tr>`).join('')}</tbody></table></div>`;
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${esc(relaunchNotice(r) || r.lastActivity || 'unknown')}</td></tr>`).join('')}</tbody></table></div>`;
}
function registered(r) {
const reg = r.registered;
@@ -51,9 +55,11 @@
if (!selected) return;
if (!r) { $('inspection').innerHTML = '<p>This session is no longer in the board scan. Its draft is kept until this page closes.</p>'; return; }
$('inspector-title').textContent = r.agent;
const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], ['Last activity', r.lastActivity || 'unknown'] ];
const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Task set by', r.taskSetBy ? `${r.taskSetBy} (as claimed by the caller, not verified)` : 'not applicable (task is not from a registration)'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], [r.relaunchedAt ? 'Historical last activity' : 'Last activity', r.lastActivity || 'unknown'] ];
if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]);
if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]);
const receipt = receipts.get(selected);
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}<dt>Last assistant text</dt><dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>Last error</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : '<p class="muted">reply needs a registered seat</p>'}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt><dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
}
// Restore by data attribute equality, never interpolate API ids into selectors.
function focusSnapshot() {
+28 -1
View File
@@ -29,12 +29,16 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
const agent = 'agent"[\\<script>', project = '__proto__';
const rec = { agent, project, state: 'waiting', waitingOnYou: true, task: 'Long '.repeat(300), workspace: '/' + 'path/'.repeat(100), taskSource: 'registration', lastActivity: '2026-09-01', lastAssistantText: '<img src=x onerror=alert(1)>', registered: { alive: true, tmux: { session: agent } } };
const rec = { agent, project, state: 'waiting', waitingOnYou: true, task: 'Long '.repeat(300), workspace: '/' + 'path/'.repeat(100), taskSource: 'registration', taskSetBy: '<img src=x onerror=alert(2)>', lastActivity: '2026-09-01', lastAssistantText: '<img src=x onerror=alert(1)>', registered: { alive: true, tmux: { session: agent } } };
index = { sessions: [rec], counts: { waiting: 1 } };
await b.evaluate('document.querySelector("#refresh").click()'); await wait('document.querySelectorAll("table.sessions [data-open]").length===1');
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
assert.equal(await b.evaluate('document.querySelector("#inspector-title").textContent'), agent);
assert.equal(await b.evaluate('document.querySelectorAll("#inspection img").length'), 0);
// #1511: a hostile setter value is text, in the table and the inspector.
assert.equal(await b.evaluate('document.querySelectorAll("img").length'), 0);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*<img src=x onerror=alert\(2\)> \(as claimed/);
assert.match(await b.evaluate('document.querySelector("table.sessions td.task").textContent'), /set by <img src=x onerror=alert\(2\)>/);
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'original draft' });
await b.evaluate('document.querySelector(".reply-form").requestSubmit();document.querySelector(".reply-form").requestSubmit()');
@@ -50,6 +54,29 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), false);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /reply needs a registered seat/);
// #1511: attribution never enables a reply: a setter with no registration at all still gets no form.
const savedRegistration = index.sessions[0].registered;
index.sessions[0].registered = null; index.sessions[0].taskSetBy = 'someone';
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), false);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*someone/);
// R1-U1: null means not applicable (task not selected from a registration); it is never shown as "unknown",
// and a previous setter never lingers after the transition.
index.sessions[0].registered = savedRegistration; index.sessions[0].taskSetBy = null;
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*not applicable \(task is not from a registration\)/);
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("someone")'), false);
assert.doesNotMatch(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown/);
assert.equal(await b.evaluate('document.querySelector("table.sessions td.task").textContent.includes("set by")'), false);
// A selected legacy registration (record predates taskSetBy) reads "unknown", distinct from not applicable.
index.sessions[0].taskSource = 'registration'; index.sessions[0].taskSetBy = 'unknown';
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown \(as claimed by the caller, not verified\)/);
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("not applicable")'), false);
assert.match(await b.evaluate('document.querySelector("table.sessions td.task").textContent'), /set by unknown/);
index.sessions[0].taskSource = 'first-user-message'; index.sessions[0].taskSetBy = null;
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("set by unknown")'), false);
index.sessions[0].registered.alive = true;
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!!document.querySelector("#reply")');
assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/);
+13
View File
@@ -34,6 +34,19 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.activeElement.id'), 'inspector-title');
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), false);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Registered fixture task/);
// #1511: attribution shows for the registered task only, as a claim, and only where the task is the registered one.
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*fixture-setter \(as claimed by the caller, not verified\)/);
assert.match(await b.evaluate('document.querySelector("table.sessions tr[data-row=\\"proj/agent1\\"] td.task").textContent'), /set by fixture-setter/);
assert.equal(await b.evaluate('[...document.querySelectorAll("table.sessions td.task")].filter(td=>td.textContent.includes("set by")).length'), 1, 'transcript-derived rows carry no attribution');
assert.match(await b.evaluate('document.querySelector("#waiting .wait-item p:nth-of-type(2)").textContent'), /set by fixture-setter|Build the fixture task/);
// R1-U1: a transcript-derived task is "not applicable", never "unknown", and never borrows the registered setter.
await b.evaluate('[...document.querySelectorAll("table.sessions [data-open]")].find(e=>e.dataset.open!=="proj/agent1").focus()'); await b.key('Enter');
await wait('!document.querySelector("#inspection").textContent.includes("Registered fixture task")');
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*not applicable \(task is not from a registration\)/);
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("fixture-setter")'), false);
assert.doesNotMatch(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown/);
await b.evaluate('[...document.querySelectorAll("table.sessions [data-open]")].find(e=>e.dataset.open==="proj/agent1").focus()'); await b.key('Enter');
await wait('document.querySelector("#inspection").textContent.includes("Registered fixture task")');
assert.equal(await b.evaluate('!!window.injected'), false);
await b.evaluate('document.querySelector("#reply").focus()');
await b.call('Input.insertText', { text: 'keep this draft' });
+62
View File
@@ -0,0 +1,62 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { identityOf } from '../../discord/src/journal.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
test('Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content', { timeout: 60000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-discord-'));
let board, web, b;
try {
const dir = join(root, 'discord', 'pilot'); mkdirSync(join(dir, 'run.lock'), { recursive: true });
writeFileSync(join(root, 'discord', 'pilot.json'), JSON.stringify({ name: 'pilot', seat: 'sage', tokenFile: '/PRIVATE-NOT-READ', guildId: 'PRIVATE-ID' }), { mode: 0o600 });
writeFileSync(join(dir, 'run.lock', 'owner.json'), JSON.stringify({ pid: process.pid, ...identityOf(process.pid) }));
writeFileSync(join(dir, 'STOP'), 'PRIVATE STOP');
const sessions = join(root, 'sessions', 'discord-pilot'); mkdirSync(sessions, { recursive: true });
writeFileSync(join(sessions, 's.jsonl'), JSON.stringify({ type: 'message', timestamp: '2026-09-14T00:00:00Z', message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: '<script>window.injected=true</script> completed' }] } }) + '\n');
// #1511: a native registration naming the connector's sessions directory, with a setter, must lend it nothing.
const seatsDir = join(root, 'seats');
writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'sage', project: 'fleet', sessionsDir: sessions, seatDir: dir, launchScript: join(root, 'unused.sh'), layout: 'fleet', defaultWorkspace: null }, task: 'forged native task', tmux: { session: 'sage', socket: null }, pid: process.pid }), taskSetBy: 'forged-setter' });
board = await startBoard({ port: 0, specs: [], seatsDir, boardDir: join(root, 'board'), discordDataRoot: root, exec: () => { throw Error('no transport'); } });
web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
const base = `http://127.0.0.1:${web.address().port}`;
const payload = await (await fetch(base + '/api/board')).json();
assert.equal(payload.sessions[0].connector.braked, true); assert.equal(payload.sessions[0].state, 'idle');
assert.equal(JSON.stringify(payload).includes('PRIVATE'), false);
assert.equal(payload.sessions[0].task, 'Discord connector'); assert.equal(payload.sessions[0].taskSource, 'connector'); assert.equal(payload.sessions[0].taskSetBy, null);
assert.equal(JSON.stringify(payload).includes('forged'), false);
const reply = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/sage (discord: pilot)', text: 'refuse me' }) });
assert.equal(reply.status, 409);
b = await browser(); await b.viewport(320, 1000);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
await b.navigate(`http://127.0.0.1:${board.address().port}`);
await wait('document.querySelector(".session-row")');
assert.match(await b.evaluate('document.body.textContent'), /braked \(STOP\)/);
assert.match(await b.evaluate('document.body.textContent'), /Board replies disabled for Discord connectors/);
assert.equal(await b.evaluate('!!document.querySelector(".reply-form")'), false);
assert.equal(await b.evaluate('!!window.injected'), false);
await b.navigate(base);
await wait('document.querySelector("table.sessions [data-open]")');
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /braked \(STOP\); owner live/);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Board replies disabled for Discord connectors/);
assert.equal(await b.evaluate('!!document.querySelector(".reply-form")'), false);
assert.equal(await b.evaluate('!!window.injected'), false);
// R1-U1: the connector's fixed task is "not applicable", never "unknown", and never the forged native setter.
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*not applicable \(task is not from a registration\)/);
assert.doesNotMatch(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown/);
assert.equal(await b.evaluate('document.body.textContent.includes("forged")'), false);
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
rmSync(join(dir, 'STOP')); rmSync(join(dir, 'run.lock', 'owner.json'));
await b.evaluate('document.querySelector("#hide-offline").click();document.querySelector("#refresh").click()');
await wait('document.querySelector("#inspection").textContent.includes("owner absent")');
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /not braked; owner absent/);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /offline/);
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
});
+7 -3
View File
@@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { makeRegistration, writeRegistration, updateTask } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
export const close = server => new Promise(resolve => { server.close(resolve); server.closeIdleConnections(); });
export async function fixture() {
@@ -14,7 +14,7 @@ export async function fixture() {
const captures = [];
for (const [agent, project, state] of [['agent1', 'proj', 'waiting'], ['agent2', 'proj', 'working'], ['agent3', 'other', 'error'], ['agent4', 'other', 'offline']]) {
const sessionsDir = join(root, agent); mkdirSync(sessionsDir);
const assistant = { role: 'assistant', model: 'fixture-model', provider: 'fixture-provider', stopReason: state === 'error' ? 'error' : 'stop', content: [{ type: 'text', text: 'done? <script>window.injected=true</script>' }] };
const assistant = { role: 'assistant', model: 'fixture-model', provider: 'fixture-provider', stopReason: state === 'error' ? 'error' : 'stop', content: [{ type: 'text', text: (state === 'waiting' ? 'Input needed: ' : '') + 'done? <script>window.injected=true</script>' }] };
const lines = [
{ type: 'session', id: agent, timestamp: '2026-09-01T00:00:00Z', cwd: '/fixture/workspace' },
{ type: 'message', timestamp: '2026-09-01T00:00:01Z', message: { role: 'user', content: [{ type: 'text', text: 'Build the fixture task' }] } },
@@ -23,7 +23,11 @@ export async function fixture() {
if (state === 'working') lines.push({ type: 'message', timestamp: '2026-09-01T00:00:03Z', message: { role: 'user', content: [{ type: 'text', text: 'continue' }] } });
writeFileSync(join(sessionsDir, 's.jsonl'), lines.map(l => JSON.stringify(l)).join('\n') + '\n');
specs.push({ agent, project, sessionsDir, tmux: { session: agent } });
if (agent === 'agent1') writeRegistration(seatsDir, makeRegistration({ resolved: { seat: agent, project, sessionsDir, seatDir: sessionsDir, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: '/fixture/workspace' }, task: 'Registered fixture task', tmux: { session: agent, socket: null }, pid: process.pid }));
if (agent === 'agent1') {
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: agent, project, sessionsDir, seatDir: sessionsDir, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: '/fixture/workspace' }, task: 'Registered fixture task', tmux: { session: agent, socket: null }, pid: process.pid }));
// #1511: the same path `mosaic seat task --by` takes, on the fixture record only.
updateTask(seatsDir, agent, 'Registered fixture task', { layout: 'repo', setBy: 'fixture-setter' });
}
}
const board = await startBoard({ port: 0, specs, seatsDir, boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true,
exec: (file, args) => { captures.push({ file, args }); return { status: exitCode, stdout: 'fixture transport', stderr: exitCode ? 'fixture refusal <unsafe>' : '' }; },
+61
View File
@@ -0,0 +1,61 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const oldAt = '2026-09-14T10:00:00.000Z', launchAt = '2026-09-15T10:00:00.000Z', newAt = '2026-09-15T10:01:00.000Z';
const notice = `relaunched at ${launchAt}, no messages since`;
test('both presentations replace old activity with relaunch notice, label retained history, then resume after new activity', { timeout: 60000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-relaunch-'));
let board, web, b;
try {
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
const file = join(sessionsDir, 's.jsonl');
const line = (at, text) => JSON.stringify({ type: 'message', timestamp: at, message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] } }) + '\n';
const history = line(oldAt, 'Input needed: OLD_FIXTURE_REPLY <img src=x onerror=alert(1)>');
writeFileSync(file, history);
const seatsDir = join(root, 'seats');
writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' });
board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: () => { throw Error('no transport expected'); } });
const boardURL = `http://127.0.0.1:${board.address().port}`;
web = await startServer({ port: 0, board: boardURL });
const webURL = `http://127.0.0.1:${web.address().port}`;
const row = (await (await fetch(webURL + '/api/board')).json()).sessions[0];
assert.equal(row.relaunchedAt, launchAt); assert.equal(row.lastActivity, oldAt);
assert.match(row.lastAssistantText, /OLD_FIXTURE_REPLY/); assert.equal(row.state, 'waiting'); assert.equal(row.taskSetBy, 'fixture-setter');
b = await browser(); await b.viewport(320, 1000);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
await b.navigate(boardURL); await wait('document.querySelector(".session-row")');
const legacyRow = await b.evaluate('document.querySelector(".session-row").textContent');
assert.ok(legacyRow.includes(notice)); assert.equal(legacyRow.includes('OLD_FIXTURE_REPLY'), false);
await b.evaluate('document.querySelector(".row-toggle").click()');
assert.match(await b.evaluate('document.querySelector(".detail-row").textContent'), /Historical last message.*OLD_FIXTURE_REPLY/s);
assert.equal(await b.evaluate('!!document.querySelector("img")'), false);
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
await b.navigate(webURL); await wait('document.querySelector("table.sessions [data-open]")');
assert.ok((await b.evaluate('document.querySelector("table.sessions").textContent')).includes(notice));
const card = await b.evaluate('document.querySelector("#waiting").textContent');
assert.ok(card.includes(notice)); assert.equal(card.includes('OLD_FIXTURE_REPLY'), false);
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
const inspector = await b.evaluate('document.querySelector("#inspection").textContent');
assert.ok(inspector.includes(notice)); assert.match(inspector, /Historical last assistant text.*OLD_FIXTURE_REPLY/s);
assert.match(inspector, /Historical last activity/); assert.match(inspector, /fixture-setter/);
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), true, 'reply eligibility unchanged');
assert.equal(await b.evaluate('!!document.querySelector("img")'), false);
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
appendFileSync(file, line(newAt, 'NEW_FIXTURE_REPLY'));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("#inspection").textContent.includes("NEW_FIXTURE_REPLY")');
assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('relaunched at'), false);
assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('Historical last'), false);
await b.navigate(boardURL); await wait('document.querySelector(".session-row")?.textContent.includes("NEW_FIXTURE_REPLY")');
assert.equal((await b.evaluate('document.querySelector(".session-row").textContent')).includes('relaunched at'), false);
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
});
+1 -1
View File
@@ -11,7 +11,7 @@ const source = resolve(import.meta.dirname, '..');
// forgets inherits these and fails instead of registering into a real data root.
process.env.MOSAIC_CONFIG = join(tmpdir(), 'mosaic-launch-test-no-config.json');
process.env.MOSAIC_LAUNCH_REGISTERED = '';
for (const agent of ['darkwing', 'dewey', 'filbert']) test(`${agent} launch context, resources, session recovery, and refusals`, () => {
for (const agent of ['darkwing', 'dewey', 'filbert', 'researcher']) test(`${agent} launch context, resources, session recovery, and refusals`, () => {
const root = mkdtempSync(join(tmpdir(), `${agent} launch `));
try {
for (const dir of ['agents', 'scripts', 'contracts', 'user', 'node_modules/.bin', 'extensions/goal']) {