@cluesmith/codev 3.2.4 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard-dist/assets/index-BZRTEMtH.css +32 -0
- package/dashboard-dist/assets/{index-BZ1x7x1X.js → index-JCbkpAcV.js} +33 -33
- package/dashboard-dist/assets/index-JCbkpAcV.js.map +1 -0
- package/dashboard-dist/index.html +2 -2
- package/dist/agent-farm/cli.d.ts.map +1 -1
- package/dist/agent-farm/cli.js +142 -0
- package/dist/agent-farm/cli.js.map +1 -1
- package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
- package/dist/agent-farm/commands/cleanup.js +16 -0
- package/dist/agent-farm/commands/cleanup.js.map +1 -1
- package/dist/agent-farm/commands/inbox.d.ts +42 -0
- package/dist/agent-farm/commands/inbox.d.ts.map +1 -0
- package/dist/agent-farm/commands/inbox.js +129 -0
- package/dist/agent-farm/commands/inbox.js.map +1 -0
- package/dist/agent-farm/commands/interrupt.d.ts +20 -0
- package/dist/agent-farm/commands/interrupt.d.ts.map +1 -0
- package/dist/agent-farm/commands/interrupt.js +65 -0
- package/dist/agent-farm/commands/interrupt.js.map +1 -0
- package/dist/agent-farm/commands/reset/constants.d.ts +52 -0
- package/dist/agent-farm/commands/reset/constants.d.ts.map +1 -0
- package/dist/agent-farm/commands/reset/constants.js +52 -0
- package/dist/agent-farm/commands/reset/constants.js.map +1 -0
- package/dist/agent-farm/commands/reset/context.d.ts +230 -0
- package/dist/agent-farm/commands/reset/context.d.ts.map +1 -0
- package/dist/agent-farm/commands/reset/context.js +428 -0
- package/dist/agent-farm/commands/reset/context.js.map +1 -0
- package/dist/agent-farm/commands/reset/index.d.ts +192 -0
- package/dist/agent-farm/commands/reset/index.d.ts.map +1 -0
- package/dist/agent-farm/commands/reset/index.js +488 -0
- package/dist/agent-farm/commands/reset/index.js.map +1 -0
- package/dist/agent-farm/commands/reset/receipt.d.ts +97 -0
- package/dist/agent-farm/commands/reset/receipt.d.ts.map +1 -0
- package/dist/agent-farm/commands/reset/receipt.js +149 -0
- package/dist/agent-farm/commands/reset/receipt.js.map +1 -0
- package/dist/agent-farm/commands/reset/reorient.d.ts +117 -0
- package/dist/agent-farm/commands/reset/reorient.d.ts.map +1 -0
- package/dist/agent-farm/commands/reset/reorient.js +313 -0
- package/dist/agent-farm/commands/reset/reorient.js.map +1 -0
- package/dist/agent-farm/commands/reset.d.ts +19 -0
- package/dist/agent-farm/commands/reset.d.ts.map +1 -0
- package/dist/agent-farm/commands/reset.js +292 -0
- package/dist/agent-farm/commands/reset.js.map +1 -0
- package/dist/agent-farm/commands/send.d.ts.map +1 -1
- package/dist/agent-farm/commands/send.js +49 -8
- package/dist/agent-farm/commands/send.js.map +1 -1
- package/dist/agent-farm/commands/spawn-worktree.d.ts +105 -6
- package/dist/agent-farm/commands/spawn-worktree.d.ts.map +1 -1
- package/dist/agent-farm/commands/spawn-worktree.js +289 -66
- package/dist/agent-farm/commands/spawn-worktree.js.map +1 -1
- package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
- package/dist/agent-farm/commands/spawn.js +13 -1
- package/dist/agent-farm/commands/spawn.js.map +1 -1
- package/dist/agent-farm/commands/status.d.ts.map +1 -1
- package/dist/agent-farm/commands/status.js +80 -8
- package/dist/agent-farm/commands/status.js.map +1 -1
- package/dist/agent-farm/db/index.d.ts +1 -1
- package/dist/agent-farm/db/index.d.ts.map +1 -1
- package/dist/agent-farm/db/index.js +80 -1
- package/dist/agent-farm/db/index.js.map +1 -1
- package/dist/agent-farm/db/mailbox.d.ts +222 -0
- package/dist/agent-farm/db/mailbox.d.ts.map +1 -0
- package/dist/agent-farm/db/mailbox.js +304 -0
- package/dist/agent-farm/db/mailbox.js.map +1 -0
- package/dist/agent-farm/db/schema.d.ts +1 -1
- package/dist/agent-farm/db/schema.d.ts.map +1 -1
- package/dist/agent-farm/db/schema.js +35 -0
- package/dist/agent-farm/db/schema.js.map +1 -1
- package/dist/agent-farm/db/types.d.ts +45 -0
- package/dist/agent-farm/db/types.d.ts.map +1 -1
- package/dist/agent-farm/db/types.js.map +1 -1
- package/dist/agent-farm/lib/builder-lookup.d.ts +1 -1
- package/dist/agent-farm/lib/builder-lookup.js +2 -2
- package/dist/agent-farm/lib/builder-lookup.js.map +1 -1
- package/dist/agent-farm/lib/reconnect-backoff.d.ts +54 -0
- package/dist/agent-farm/lib/reconnect-backoff.d.ts.map +1 -0
- package/dist/agent-farm/lib/reconnect-backoff.js +47 -0
- package/dist/agent-farm/lib/reconnect-backoff.js.map +1 -0
- package/dist/agent-farm/lib/tower-client.d.ts +16 -6
- package/dist/agent-farm/lib/tower-client.d.ts.map +1 -1
- package/dist/agent-farm/lib/tower-client.js +37 -6
- package/dist/agent-farm/lib/tower-client.js.map +1 -1
- package/dist/agent-farm/lib/tunnel-client.d.ts +69 -1
- package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -1
- package/dist/agent-farm/lib/tunnel-client.js +218 -27
- package/dist/agent-farm/lib/tunnel-client.js.map +1 -1
- package/dist/agent-farm/servers/cron-delivery.d.ts +72 -0
- package/dist/agent-farm/servers/cron-delivery.d.ts.map +1 -0
- package/dist/agent-farm/servers/cron-delivery.js +80 -0
- package/dist/agent-farm/servers/cron-delivery.js.map +1 -0
- package/dist/agent-farm/servers/delayed-send.d.ts +85 -0
- package/dist/agent-farm/servers/delayed-send.d.ts.map +1 -0
- package/dist/agent-farm/servers/delayed-send.js +165 -0
- package/dist/agent-farm/servers/delayed-send.js.map +1 -0
- package/dist/agent-farm/servers/gate-profiles.d.ts +67 -0
- package/dist/agent-farm/servers/gate-profiles.d.ts.map +1 -0
- package/dist/agent-farm/servers/gate-profiles.js +113 -0
- package/dist/agent-farm/servers/gate-profiles.js.map +1 -0
- package/dist/agent-farm/servers/mailbox-delivery.d.ts +370 -0
- package/dist/agent-farm/servers/mailbox-delivery.d.ts.map +1 -0
- package/dist/agent-farm/servers/mailbox-delivery.js +617 -0
- package/dist/agent-farm/servers/mailbox-delivery.js.map +1 -0
- package/dist/agent-farm/servers/mailbox-wiring.d.ts +125 -0
- package/dist/agent-farm/servers/mailbox-wiring.d.ts.map +1 -0
- package/dist/agent-farm/servers/mailbox-wiring.js +425 -0
- package/dist/agent-farm/servers/mailbox-wiring.js.map +1 -0
- package/dist/agent-farm/servers/message-write.d.ts +53 -1
- package/dist/agent-farm/servers/message-write.d.ts.map +1 -1
- package/dist/agent-farm/servers/message-write.js +63 -0
- package/dist/agent-farm/servers/message-write.js.map +1 -1
- package/dist/agent-farm/servers/overview.d.ts.map +1 -1
- package/dist/agent-farm/servers/overview.js +23 -4
- package/dist/agent-farm/servers/overview.js.map +1 -1
- package/dist/agent-farm/servers/render-gate.d.ts +141 -0
- package/dist/agent-farm/servers/render-gate.d.ts.map +1 -0
- package/dist/agent-farm/servers/render-gate.js +251 -0
- package/dist/agent-farm/servers/render-gate.js.map +1 -0
- package/dist/agent-farm/servers/session-submit.d.ts +90 -0
- package/dist/agent-farm/servers/session-submit.d.ts.map +1 -0
- package/dist/agent-farm/servers/session-submit.js +127 -0
- package/dist/agent-farm/servers/session-submit.js.map +1 -0
- package/dist/agent-farm/servers/tower-cron.d.ts +10 -7
- package/dist/agent-farm/servers/tower-cron.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-cron.js +58 -53
- package/dist/agent-farm/servers/tower-cron.js.map +1 -1
- package/dist/agent-farm/servers/tower-instances.d.ts +14 -2
- package/dist/agent-farm/servers/tower-instances.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-instances.js +78 -16
- package/dist/agent-farm/servers/tower-instances.js.map +1 -1
- package/dist/agent-farm/servers/tower-messages.d.ts +38 -1
- package/dist/agent-farm/servers/tower-messages.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-messages.js +106 -1
- package/dist/agent-farm/servers/tower-messages.js.map +1 -1
- package/dist/agent-farm/servers/tower-routes.d.ts +16 -4
- package/dist/agent-farm/servers/tower-routes.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-routes.js +670 -105
- package/dist/agent-farm/servers/tower-routes.js.map +1 -1
- package/dist/agent-farm/servers/tower-server.js +163 -31
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/servers/tower-terminals.d.ts +17 -2
- package/dist/agent-farm/servers/tower-terminals.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-terminals.js +104 -122
- package/dist/agent-farm/servers/tower-terminals.js.map +1 -1
- package/dist/agent-farm/servers/tower-tunnel.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-tunnel.js +63 -4
- package/dist/agent-farm/servers/tower-tunnel.js.map +1 -1
- package/dist/agent-farm/servers/tower-types.d.ts +2 -1
- package/dist/agent-farm/servers/tower-types.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-utils.d.ts +64 -1
- package/dist/agent-farm/servers/tower-utils.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-utils.js +168 -1
- package/dist/agent-farm/servers/tower-utils.js.map +1 -1
- package/dist/agent-farm/servers/tower-websocket.d.ts.map +1 -1
- package/dist/agent-farm/servers/tower-websocket.js +6 -21
- package/dist/agent-farm/servers/tower-websocket.js.map +1 -1
- package/dist/agent-farm/servers/write-queue.d.ts +35 -0
- package/dist/agent-farm/servers/write-queue.d.ts.map +1 -0
- package/dist/agent-farm/servers/write-queue.js +56 -0
- package/dist/agent-farm/servers/write-queue.js.map +1 -0
- package/dist/agent-farm/types.d.ts +39 -0
- package/dist/agent-farm/types.d.ts.map +1 -1
- package/dist/agent-farm/utils/agent-names.d.ts +2 -2
- package/dist/agent-farm/utils/agent-names.d.ts.map +1 -1
- package/dist/agent-farm/utils/agent-names.js +3 -3
- package/dist/agent-farm/utils/agent-names.js.map +1 -1
- package/dist/agent-farm/utils/architect-name.d.ts +2 -2
- package/dist/agent-farm/utils/architect-name.d.ts.map +1 -1
- package/dist/agent-farm/utils/architect-name.js +3 -3
- package/dist/agent-farm/utils/architect-name.js.map +1 -1
- package/dist/agent-farm/utils/config.d.ts +13 -0
- package/dist/agent-farm/utils/config.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.js +33 -1
- package/dist/agent-farm/utils/config.js.map +1 -1
- package/dist/agent-farm/utils/harness.d.ts +103 -7
- package/dist/agent-farm/utils/harness.d.ts.map +1 -1
- package/dist/agent-farm/utils/harness.js +126 -19
- package/dist/agent-farm/utils/harness.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -32
- package/dist/cli.js.map +1 -1
- package/dist/commands/consult/cli-options.d.ts +34 -0
- package/dist/commands/consult/cli-options.d.ts.map +1 -0
- package/dist/commands/consult/cli-options.js +67 -0
- package/dist/commands/consult/cli-options.js.map +1 -0
- package/dist/commands/consult/index.d.ts +56 -2
- package/dist/commands/consult/index.d.ts.map +1 -1
- package/dist/commands/consult/index.js +272 -28
- package/dist/commands/consult/index.js.map +1 -1
- package/dist/commands/consult/metrics.d.ts +42 -0
- package/dist/commands/consult/metrics.d.ts.map +1 -1
- package/dist/commands/consult/metrics.js +129 -9
- package/dist/commands/consult/metrics.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +71 -19
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/porch/checks.d.ts +23 -0
- package/dist/commands/porch/checks.d.ts.map +1 -1
- package/dist/commands/porch/checks.js +60 -0
- package/dist/commands/porch/checks.js.map +1 -1
- package/dist/commands/porch/config.d.ts +20 -0
- package/dist/commands/porch/config.d.ts.map +1 -1
- package/dist/commands/porch/config.js +20 -0
- package/dist/commands/porch/config.js.map +1 -1
- package/dist/commands/porch/index.js +21 -28
- package/dist/commands/porch/index.js.map +1 -1
- package/dist/commands/porch/next.d.ts.map +1 -1
- package/dist/commands/porch/next.js +2 -41
- package/dist/commands/porch/next.js.map +1 -1
- package/dist/lib/config.d.ts +69 -3
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +67 -3
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/consult-lanes.d.ts +149 -0
- package/dist/lib/consult-lanes.d.ts.map +1 -0
- package/dist/lib/consult-lanes.js +360 -0
- package/dist/lib/consult-lanes.js.map +1 -0
- package/dist/lib/github.js +1 -1
- package/dist/lib/github.js.map +1 -1
- package/dist/lib/prompt-behavior-metrics.d.ts +67 -0
- package/dist/lib/prompt-behavior-metrics.d.ts.map +1 -0
- package/dist/lib/prompt-behavior-metrics.js +290 -0
- package/dist/lib/prompt-behavior-metrics.js.map +1 -0
- package/dist/lib/skeleton.d.ts +22 -1
- package/dist/lib/skeleton.d.ts.map +1 -1
- package/dist/lib/skeleton.js +120 -0
- package/dist/lib/skeleton.js.map +1 -1
- package/dist/lib/test-env.d.ts +74 -0
- package/dist/lib/test-env.d.ts.map +1 -0
- package/dist/lib/test-env.js +92 -0
- package/dist/lib/test-env.js.map +1 -0
- package/dist/terminal/pty-manager.d.ts +2 -0
- package/dist/terminal/pty-manager.d.ts.map +1 -1
- package/dist/terminal/pty-manager.js +16 -6
- package/dist/terminal/pty-manager.js.map +1 -1
- package/dist/terminal/pty-session.d.ts +131 -0
- package/dist/terminal/pty-session.d.ts.map +1 -1
- package/dist/terminal/pty-session.js +232 -39
- package/dist/terminal/pty-session.js.map +1 -1
- package/dist/terminal/ring-buffer.d.ts +39 -6
- package/dist/terminal/ring-buffer.d.ts.map +1 -1
- package/dist/terminal/ring-buffer.js +74 -6
- package/dist/terminal/ring-buffer.js.map +1 -1
- package/dist/terminal/session-manager.d.ts +53 -0
- package/dist/terminal/session-manager.d.ts.map +1 -1
- package/dist/terminal/session-manager.js +94 -9
- package/dist/terminal/session-manager.js.map +1 -1
- package/dist/terminal/session-screen.d.ts +91 -0
- package/dist/terminal/session-screen.d.ts.map +1 -0
- package/dist/terminal/session-screen.js +135 -0
- package/dist/terminal/session-screen.js.map +1 -0
- package/dist/terminal/shellper-main.js +2 -1
- package/dist/terminal/shellper-main.js.map +1 -1
- package/dist/terminal/shellper-process.d.ts +1 -1
- package/dist/terminal/shellper-process.d.ts.map +1 -1
- package/dist/terminal/shellper-process.js +17 -8
- package/dist/terminal/shellper-process.js.map +1 -1
- package/dist/terminal/shellper-replay-buffer.d.ts +69 -7
- package/dist/terminal/shellper-replay-buffer.d.ts.map +1 -1
- package/dist/terminal/shellper-replay-buffer.js +167 -31
- package/dist/terminal/shellper-replay-buffer.js.map +1 -1
- package/package.json +8 -6
- package/skeleton/.claude/skills/afx/SKILL.md +14 -1
- package/skeleton/.claude/skills/arch-init/SKILL.md +22 -9
- package/skeleton/.claude/skills/arch-save/SKILL.md +183 -0
- package/skeleton/.claude/skills/codev/SKILL.md +31 -0
- package/skeleton/.claude/skills/runnable-worktrees/SKILL.md +119 -0
- package/skeleton/.codex/skills/afx/SKILL.md +14 -1
- package/skeleton/.codex/skills/arch-init/SKILL.md +22 -9
- package/skeleton/.codex/skills/arch-save/SKILL.md +183 -0
- package/skeleton/.codex/skills/codev/SKILL.md +31 -0
- package/skeleton/.codex/skills/runnable-worktrees/SKILL.md +119 -0
- package/skeleton/protocols/air/builder-prompt.md +28 -47
- package/skeleton/protocols/air/consult-types/impl-review.md +17 -34
- package/skeleton/protocols/air/consult-types/pr-review.md +14 -37
- package/skeleton/protocols/air/prompts/implement.md +20 -73
- package/skeleton/protocols/air/prompts/pr.md +22 -54
- package/skeleton/protocols/air/protocol.md +40 -71
- package/skeleton/protocols/aspir/builder-prompt.md +29 -66
- package/skeleton/protocols/aspir/consult-types/impl-review.md +17 -44
- package/skeleton/protocols/aspir/consult-types/phase-review.md +17 -44
- package/skeleton/protocols/aspir/consult-types/plan-review.md +14 -38
- package/skeleton/protocols/aspir/consult-types/pr-review.md +13 -39
- package/skeleton/protocols/aspir/consult-types/spec-review.md +13 -33
- package/skeleton/protocols/aspir/prompts/implement.md +27 -196
- package/skeleton/protocols/aspir/prompts/plan.md +21 -85
- package/skeleton/protocols/aspir/prompts/review.md +29 -228
- package/skeleton/protocols/aspir/prompts/specify.md +25 -111
- package/skeleton/protocols/aspir/protocol.json +10 -0
- package/skeleton/protocols/aspir/protocol.md +36 -82
- package/skeleton/protocols/bugfix/builder-prompt.md +23 -45
- package/skeleton/protocols/bugfix/consult-types/impl-review.md +21 -45
- package/skeleton/protocols/bugfix/consult-types/pr-review.md +18 -52
- package/skeleton/protocols/bugfix/prompts/fix.md +16 -61
- package/skeleton/protocols/bugfix/prompts/investigate.md +18 -62
- package/skeleton/protocols/bugfix/prompts/pr.md +22 -57
- package/skeleton/protocols/bugfix/protocol.md +44 -50
- package/skeleton/protocols/experiment/builder-prompt.md +25 -58
- package/skeleton/protocols/experiment/protocol.md +22 -188
- package/skeleton/protocols/experiment/templates/notes.md +11 -71
- package/skeleton/protocols/maintain/builder-prompt.md +24 -45
- package/skeleton/protocols/maintain/consult-types/impl-review.md +17 -44
- package/skeleton/protocols/maintain/consult-types/pr-review.md +13 -39
- package/skeleton/protocols/maintain/prompts/maintain.md +1 -1
- package/skeleton/protocols/maintain/protocol.md +32 -266
- package/skeleton/protocols/maintain/templates/maintenance-run.md +11 -0
- package/skeleton/protocols/pir/builder-prompt.md +30 -64
- package/skeleton/protocols/pir/consult-types/impl-review.md +17 -50
- package/skeleton/protocols/pir/consult-types/pr-review.md +11 -40
- package/skeleton/protocols/pir/prompts/implement.md +1 -2
- package/skeleton/protocols/pir/prompts/plan.md +0 -3
- package/skeleton/protocols/pir/prompts/review.md +1 -1
- package/skeleton/protocols/pir/protocol.md +54 -177
- package/skeleton/protocols/research/builder-prompt.md +27 -58
- package/skeleton/protocols/research/protocol.md +28 -153
- package/skeleton/protocols/spike/builder-prompt.md +30 -48
- package/skeleton/protocols/spike/protocol.md +24 -107
- package/skeleton/protocols/spike/templates/findings.md +9 -39
- package/skeleton/protocols/spir/builder-prompt.md +33 -62
- package/skeleton/protocols/spir/consult-types/impl-review.md +17 -44
- package/skeleton/protocols/spir/consult-types/phase-review.md +17 -44
- package/skeleton/protocols/spir/consult-types/plan-review.md +14 -38
- package/skeleton/protocols/spir/consult-types/pr-review.md +13 -39
- package/skeleton/protocols/spir/consult-types/spec-review.md +13 -33
- package/skeleton/protocols/spir/prompts/implement.md +26 -195
- package/skeleton/protocols/spir/prompts/plan.md +20 -84
- package/skeleton/protocols/spir/prompts/review.md +28 -227
- package/skeleton/protocols/spir/prompts/specify.md +24 -110
- package/skeleton/protocols/spir/protocol.json +10 -0
- package/skeleton/protocols/spir/protocol.md +84 -633
- package/skeleton/protocols/spir/templates/plan.md +28 -147
- package/skeleton/protocols/spir/templates/review.md +21 -80
- package/skeleton/protocols/spir/templates/spec.md +24 -118
- package/skeleton/resources/commands/agent-farm.md +259 -0
- package/skeleton/resources/commands/consult.md +236 -8
- package/skeleton/resources/commands/overview.md +1 -0
- package/skeleton/roles/architect.md +67 -317
- package/skeleton/roles/builder.md +77 -191
- package/skeleton/templates/AGENTS.md +2 -0
- package/skeleton/templates/CLAUDE.md +2 -0
- package/templates/tower.html +39 -2
- package/dashboard-dist/assets/index-BZ1x7x1X.js.map +0 -1
- package/dashboard-dist/assets/index-DB1KBSNE.css +0 -32
- package/dist/agent-farm/servers/send-buffer.d.ts +0 -59
- package/dist/agent-farm/servers/send-buffer.d.ts.map +0 -1
- package/dist/agent-farm/servers/send-buffer.js +0 -116
- package/dist/agent-farm/servers/send-buffer.js.map +0 -1
- package/skeleton/porch/prompts/defend.md +0 -108
- package/skeleton/porch/prompts/diagnose.md +0 -70
- package/skeleton/porch/prompts/evaluate.md +0 -132
- package/skeleton/porch/prompts/fix.md +0 -59
- package/skeleton/porch/prompts/implement.md +0 -88
- package/skeleton/porch/prompts/plan.md +0 -74
- package/skeleton/porch/prompts/pr.md +0 -84
- package/skeleton/porch/prompts/review.md +0 -249
- package/skeleton/porch/prompts/specify.md +0 -53
- package/skeleton/porch/prompts/test.md +0 -63
- package/skeleton/protocols/aspir/templates/plan.md +0 -184
- package/skeleton/protocols/aspir/templates/review.md +0 -120
- package/skeleton/protocols/aspir/templates/spec.md +0 -154
|
@@ -31,12 +31,25 @@ import { getWorktreeConfig, getActivityHooks } from '../utils/config.js';
|
|
|
31
31
|
import { ensureCodevConfigWatcher } from './codev-config-watcher.js';
|
|
32
32
|
import { hasTeam, loadTeamMembers, loadMessages } from '../../lib/team.js';
|
|
33
33
|
import { fetchTeamGitHubData } from '../../lib/team-github.js';
|
|
34
|
-
import { resolveTarget, broadcastMessage, isResolveError } from './tower-messages.js';
|
|
34
|
+
import { resolveTarget, resolveAgentInRegistry, broadcastMessage, isResolveError } from './tower-messages.js';
|
|
35
35
|
import { handleCommandRoute, COMMAND_ROUTE } from './command-relay.js';
|
|
36
36
|
import { formatArchitectMessage, formatBuilderMessage } from '../utils/message-format.js';
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
37
|
+
import { writeMessageToSession, writeEscapeToSession } from './message-write.js';
|
|
38
|
+
import { makeDeliveryPorts, getMailboxDrainer } from './mailbox-wiring.js';
|
|
39
|
+
import { deliverAgentMailSerialized } from './mailbox-delivery.js';
|
|
40
|
+
import { deliverCronMail, CRON_SENDER } from './cron-delivery.js';
|
|
41
|
+
import { enqueue as enqueueMailbox, getById as getMailboxById, markDelivered as markMailboxDelivered, listHeld as listHeldMailbox, dismiss as dismissMailbox, } from '../db/mailbox.js';
|
|
42
|
+
// Spec 1273 per-terminal submission lock — preserved across the Spec 1313 merge for
|
|
43
|
+
// the two explicit human-bypass paths (escape + interrupt), which do NOT route
|
|
44
|
+
// through the mailbox's per-agent serializer and so need their own anti-fusion lock.
|
|
45
|
+
import { submitToSession } from './session-submit.js';
|
|
46
|
+
// Spec 1307 `--delay` — Tower-side deferred delivery, re-homed onto the Spec 1313
|
|
47
|
+
// mailbox (the merge that carried this feature was flattened by a later rebase, so it
|
|
48
|
+
// is grafted here explicitly): the due-time callback enqueues to the mailbox and
|
|
49
|
+
// triggers a gated drain (see handleSend), so a delayed message delivers onto a
|
|
50
|
+
// render-verified empty prompt like any normal send — never force-injected.
|
|
51
|
+
import { scheduleDelayedSend, validateDelaySeconds } from './delayed-send.js';
|
|
52
|
+
import { getKnownWorkspacePaths, getInstances, getDirectorySuggestions, launchInstance, killTerminalWithShellper, instancesReady, stopInstance, addArchitect, removeArchitect, } from './tower-instances.js';
|
|
40
53
|
import { OverviewCache } from './overview.js';
|
|
41
54
|
import { fetchIssue, searchIssues, fetchPRList, fetchCurrentUser, parseLinkedIssue, parseArea, } from '../../lib/github.js';
|
|
42
55
|
import { computeAnalytics } from './analytics.js';
|
|
@@ -50,23 +63,6 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
50
63
|
const __dirname = path.dirname(__filename);
|
|
51
64
|
// Singleton cache for overview endpoint (Spec 0126 Phase 4)
|
|
52
65
|
const overviewCache = new OverviewCache();
|
|
53
|
-
// Singleton send buffer for typing-aware message delivery (Spec 403)
|
|
54
|
-
const sendBuffer = new SendBuffer();
|
|
55
|
-
/** Deliver a buffered message to a session (write + broadcast + log).
|
|
56
|
-
* Returns the ms timestamp when all writes complete (for serialization). */
|
|
57
|
-
function deliverBufferedMessage(session, msg, delayOffset = 0) {
|
|
58
|
-
const endTime = writeMessageToSession(session, msg.formattedMessage, msg.noEnter, delayOffset);
|
|
59
|
-
broadcastMessage(msg.broadcastPayload);
|
|
60
|
-
return endTime;
|
|
61
|
-
}
|
|
62
|
-
/** Start the send buffer flush timer (called from tower-server during init). */
|
|
63
|
-
export function startSendBuffer(log) {
|
|
64
|
-
sendBuffer.start((id) => getTerminalManager().getSession(id), deliverBufferedMessage, log);
|
|
65
|
-
}
|
|
66
|
-
/** Stop the send buffer and deliver remaining messages (called from tower-server during shutdown). */
|
|
67
|
-
export function stopSendBuffer() {
|
|
68
|
-
sendBuffer.stop();
|
|
69
|
-
}
|
|
70
66
|
const ROUTES = {
|
|
71
67
|
'GET /health': (_req, res, _url, ctx) => handleHealthCheck(res, ctx),
|
|
72
68
|
'GET /api/shellpers/husks': (_req, res, _url, ctx) => handleHuskPreview(res, ctx),
|
|
@@ -90,10 +86,29 @@ const ROUTES = {
|
|
|
90
86
|
'POST /api/launch': (req, res) => handleLaunchInstance(req, res),
|
|
91
87
|
'POST /api/stop': (req, res) => handleStopInstance(req, res),
|
|
92
88
|
'POST /api/send': (req, res, _url, ctx) => handleSend(req, res, ctx),
|
|
89
|
+
'GET /api/inbox': (_req, res, url) => handleInboxList(res, url),
|
|
93
90
|
'GET /api/cron/tasks': (_req, res, url) => handleCronList(res, url),
|
|
94
91
|
'GET /': (_req, res, _url, ctx) => handleDashboard(res, ctx),
|
|
95
92
|
'GET /index.html': (_req, res, _url, ctx) => handleDashboard(res, ctx),
|
|
96
93
|
};
|
|
94
|
+
/**
|
|
95
|
+
* Issue #1261: tell "Tower isn't wired up yet" apart from "no such thing".
|
|
96
|
+
* A route that needs the instances module must not report a missing terminal
|
|
97
|
+
* (404) or a successful kill (204) when the truth is that it could not act at
|
|
98
|
+
* all. 503 + Retry-After says so, and matches what `stopInstance()` already
|
|
99
|
+
* returns in the same situation.
|
|
100
|
+
*
|
|
101
|
+
* Tower's readiness gate holds requests until boot completes, so these guards
|
|
102
|
+
* should be unreachable in practice — they exist so the failure mode, if the
|
|
103
|
+
* gate is ever bypassed, is honest rather than misleading.
|
|
104
|
+
*/
|
|
105
|
+
function respondStartingUp(res) {
|
|
106
|
+
res.writeHead(503, { 'Content-Type': 'application/json', 'Retry-After': '1' });
|
|
107
|
+
res.end(JSON.stringify({
|
|
108
|
+
error: 'STARTING_UP',
|
|
109
|
+
message: 'Tower is still starting up. Try again shortly.',
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
97
112
|
// ============================================================================
|
|
98
113
|
// Main request handler
|
|
99
114
|
// ============================================================================
|
|
@@ -165,6 +180,18 @@ export async function handleRequest(req, res, ctx) {
|
|
|
165
180
|
if (cronTaskMatch) {
|
|
166
181
|
return await handleCronTaskAction(req, res, url, cronTaskMatch);
|
|
167
182
|
}
|
|
183
|
+
// Inbox dismiss: POST /api/inbox/:id/dismiss (Spec 1313, Phase 7)
|
|
184
|
+
const inboxDismissMatch = url.pathname.match(/^\/api\/inbox\/([^/]+)\/dismiss$/);
|
|
185
|
+
if (inboxDismissMatch) {
|
|
186
|
+
return handleInboxDismiss(req, res, ctx, inboxDismissMatch);
|
|
187
|
+
}
|
|
188
|
+
// Inbox show: GET /api/inbox/:id — a single row INCLUDING its body (Spec 1313 §178).
|
|
189
|
+
// Checked AFTER the dismiss match, so /:id/dismiss never falls through here (its
|
|
190
|
+
// trailing segment can't match this single-segment pattern anyway).
|
|
191
|
+
const inboxShowMatch = url.pathname.match(/^\/api\/inbox\/([^/]+)$/);
|
|
192
|
+
if (inboxShowMatch) {
|
|
193
|
+
return handleInboxShow(req, res, inboxShowMatch);
|
|
194
|
+
}
|
|
168
195
|
// Workspace routes: /workspace/:base64urlPath/* (Spec 0090 Phase 4)
|
|
169
196
|
if (url.pathname.startsWith('/workspace/')) {
|
|
170
197
|
return await handleWorkspaceRoutes(req, res, ctx, url);
|
|
@@ -581,6 +608,11 @@ async function handleTerminalCreate(req, res, ctx) {
|
|
|
581
608
|
const session = manager.createSessionRaw({
|
|
582
609
|
label: label || `terminal-${sessionId.slice(0, 8)}`,
|
|
583
610
|
cwd,
|
|
611
|
+
// Spec 1313: thread the launch command so the render-gate can resolve
|
|
612
|
+
// this session's profile (builders keep the `.builder-start.sh` backstop
|
|
613
|
+
// too; this makes identity direct and restart-safe via the persisted row).
|
|
614
|
+
command,
|
|
615
|
+
args,
|
|
584
616
|
});
|
|
585
617
|
const ptySession = manager.getSession(session.id);
|
|
586
618
|
if (ptySession) {
|
|
@@ -597,7 +629,7 @@ async function handleTerminalCreate(req, res, ctx) {
|
|
|
597
629
|
else {
|
|
598
630
|
entry.shells.set(roleId, session.id);
|
|
599
631
|
}
|
|
600
|
-
saveTerminalSession(session.id, workspacePath, termType, roleId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, label ?? null, cwd ?? null);
|
|
632
|
+
saveTerminalSession(session.id, workspacePath, termType, roleId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, label ?? null, cwd ?? null, command ?? null);
|
|
601
633
|
ctx.log('INFO', `Registered shellper terminal ${session.id} as ${termType} "${roleId}" for workspace ${workspacePath}`);
|
|
602
634
|
}
|
|
603
635
|
}
|
|
@@ -619,7 +651,7 @@ async function handleTerminalCreate(req, res, ctx) {
|
|
|
619
651
|
else {
|
|
620
652
|
entry.shells.set(roleId, info.id);
|
|
621
653
|
}
|
|
622
|
-
saveTerminalSession(info.id, workspacePath, termType, roleId, info.pid, null, null, null, null, cwd ?? null);
|
|
654
|
+
saveTerminalSession(info.id, workspacePath, termType, roleId, info.pid, null, null, null, null, cwd ?? null, command ?? null);
|
|
623
655
|
ctx.log('WARN', `Terminal ${info.id} for ${workspacePath} is non-persistent (shellper unavailable)`);
|
|
624
656
|
}
|
|
625
657
|
}
|
|
@@ -656,6 +688,12 @@ async function handleTerminalRoutes(req, res, url, match) {
|
|
|
656
688
|
}
|
|
657
689
|
// DELETE /api/terminals/:id - Kill terminal (disable shellper auto-restart if applicable)
|
|
658
690
|
if (req.method === 'DELETE' && (!subpath || subpath === '')) {
|
|
691
|
+
// Issue #1261: without this, a not-yet-wired Tower answers 404 for a
|
|
692
|
+
// terminal that exists — the exact symptom the issue reports.
|
|
693
|
+
if (!instancesReady()) {
|
|
694
|
+
respondStartingUp(res);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
659
697
|
if (!(await killTerminalWithShellper(manager, terminalId))) {
|
|
660
698
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
661
699
|
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
@@ -852,7 +890,7 @@ async function handleOverview(res, url, workspaceOverride, ctx) {
|
|
|
852
890
|
// every collection field is required ('never undefined' for `architects`,
|
|
853
891
|
// Issue 1104), so emit them all empty rather than a partial payload.
|
|
854
892
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
855
|
-
res.end(JSON.stringify({ builders: [], pendingPRs: [], backlog: [], recentlyClosed: [], architects: [] }));
|
|
893
|
+
res.end(JSON.stringify({ builders: [], pendingPRs: [], backlog: [], recentlyClosed: [], architects: [], heldCount: 0, mailboxEscalated: false }));
|
|
856
894
|
return;
|
|
857
895
|
}
|
|
858
896
|
// Build set of active builder role_ids (lowercased) from live terminal sessions.
|
|
@@ -1161,6 +1199,249 @@ async function handleNotify(req, res, ctx) {
|
|
|
1161
1199
|
// ============================================================================
|
|
1162
1200
|
// POST /api/send — send a message to a resolved agent terminal
|
|
1163
1201
|
// ============================================================================
|
|
1202
|
+
/** Minimal JSON responder for the send route. */
|
|
1203
|
+
function sendJson(res, status, payload) {
|
|
1204
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
1205
|
+
res.end(JSON.stringify(payload));
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* The specific architect NAME whose live terminal is `terminalId`, or null when
|
|
1209
|
+
* `terminalId` is a builder/shell terminal. Reverse-maps via the routing registry
|
|
1210
|
+
* (Spec 1313): storing the canonical architect name on a mailbox row is what lets
|
|
1211
|
+
* held mail redeliver to the right terminal after a respawn, and it also tells an
|
|
1212
|
+
* architect target from a builder target for message formatting.
|
|
1213
|
+
*/
|
|
1214
|
+
function architectNameForTerminal(workspacePath, terminalId) {
|
|
1215
|
+
const entry = getWorkspaceTerminals().get(workspacePath);
|
|
1216
|
+
if (!entry)
|
|
1217
|
+
return null;
|
|
1218
|
+
for (const [name, tid] of entry.architects) {
|
|
1219
|
+
if (tid === terminalId)
|
|
1220
|
+
return name;
|
|
1221
|
+
}
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* The canonical mailbox identity of a live-resolved target: architect targets are
|
|
1226
|
+
* stored under their SPECIFIC architect name (reverse-mapped from the terminal),
|
|
1227
|
+
* everything else under its resolved agent id.
|
|
1228
|
+
*/
|
|
1229
|
+
function liveTargetIdentity(result) {
|
|
1230
|
+
const archName = architectNameForTerminal(result.workspacePath, result.terminalId);
|
|
1231
|
+
return { toAgent: archName ?? result.agent, isArchitectTarget: archName !== null };
|
|
1232
|
+
}
|
|
1233
|
+
/** Format a message per sender/target — preserves the pre-1313 formatting rules. */
|
|
1234
|
+
function formatMessageForTarget(isArchitectTarget, from, message, raw) {
|
|
1235
|
+
if (isArchitectTarget && from)
|
|
1236
|
+
return formatBuilderMessage(from, message, undefined, raw); // builder → architect
|
|
1237
|
+
if (!isArchitectTarget)
|
|
1238
|
+
return formatArchitectMessage(message, undefined, raw); // any → builder
|
|
1239
|
+
return raw ? message : formatArchitectMessage(message, undefined, false); // unknown → architect
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Route a cron notification through the Spec 1313 mailbox + gate — the Tower-wired
|
|
1243
|
+
* front half of {@link deliverCronMail} (Phase 6). Resolves the task's target to a
|
|
1244
|
+
* canonical recipient agent: a live terminal via {@link resolveTarget} plus the
|
|
1245
|
+
* architect reverse-map ({@link liveTargetIdentity}, because a bare `architect`
|
|
1246
|
+
* target resolves to the generic id, which the mailbox can't address), or — when the
|
|
1247
|
+
* agent is known but has no live PTY — via {@link resolveAgentInRegistry}, so the
|
|
1248
|
+
* message HOLDS as `no-live-pty` instead of vanishing (spec decision 9). Then it
|
|
1249
|
+
* hands off to the registry-free core. Cron is a non-builder sender, so no
|
|
1250
|
+
* sender-affinity or spoofing check applies. Wired into the cron scheduler as its
|
|
1251
|
+
* `deliver` port (see `initCron`), keeping the scheduler ignorant of mailbox
|
|
1252
|
+
* internals and giving cron exactly one gated path shared with `handleSend`.
|
|
1253
|
+
*/
|
|
1254
|
+
export async function deliverCronMessage(task, message, log) {
|
|
1255
|
+
const db = getGlobalDb();
|
|
1256
|
+
const ports = makeDeliveryPorts(log);
|
|
1257
|
+
// Preserve the pre-1313 cron framing: a message FROM the `af-cron` pseudo-builder,
|
|
1258
|
+
// regardless of whether the target is an architect or a builder.
|
|
1259
|
+
const base = {
|
|
1260
|
+
body: message,
|
|
1261
|
+
formattedMessage: formatBuilderMessage(CRON_SENDER, message),
|
|
1262
|
+
supersedeKey: task.name,
|
|
1263
|
+
};
|
|
1264
|
+
const live = resolveTarget(task.target, task.workspacePath);
|
|
1265
|
+
if (!isResolveError(live)) {
|
|
1266
|
+
const { toAgent } = liveTargetIdentity(live);
|
|
1267
|
+
return deliverCronMail(ports, db, {
|
|
1268
|
+
...base,
|
|
1269
|
+
workspacePath: live.workspacePath,
|
|
1270
|
+
toAgent,
|
|
1271
|
+
terminalId: live.terminalId,
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
// Live resolution failed. A NOT_FOUND target may still be a known agent with no
|
|
1275
|
+
// live PTY (Tower restarting, builder between respawns) — hold its mail so a
|
|
1276
|
+
// respawn drains it, instead of the old blind drop-with-WARN (decision 9).
|
|
1277
|
+
if (live.code === 'NOT_FOUND') {
|
|
1278
|
+
const reg = resolveAgentInRegistry(task.target, task.workspacePath);
|
|
1279
|
+
if (!isResolveError(reg)) {
|
|
1280
|
+
return deliverCronMail(ports, db, {
|
|
1281
|
+
...base,
|
|
1282
|
+
workspacePath: reg.workspacePath,
|
|
1283
|
+
toAgent: reg.agent,
|
|
1284
|
+
terminalId: null,
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
log('WARN', `Cron '${task.name}': target '${task.target}' not found — message not delivered`);
|
|
1289
|
+
return { outcome: 'unresolved', reason: null, mailboxId: null };
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Persist a `held` mailbox row and write the Spec 1313 `held` send response. Used
|
|
1293
|
+
* for both dead-session cases (no live PTY, held `no-live-pty`). The row exists
|
|
1294
|
+
* before the response returns, so the backstop drainer will redeliver it once the
|
|
1295
|
+
* agent has a clean prompt — nothing is dropped.
|
|
1296
|
+
*/
|
|
1297
|
+
function holdAndRespond(res, ctx, input, reason) {
|
|
1298
|
+
const row = enqueueMailbox(getGlobalDb(), { ...input, reason });
|
|
1299
|
+
ctx.log('INFO', `Message held (${reason}) → ${input.toAgent} @ ${path.basename(input.workspacePath)} (mailbox ${row.id.slice(0, 8)}...)`);
|
|
1300
|
+
// A new held row appeared → refresh the held-count indicator (Spec 1313, Phase 7).
|
|
1301
|
+
ctx.broadcastNotification({ type: 'overview-changed', title: 'Held mail changed', body: `held ${reason}` });
|
|
1302
|
+
sendJson(res, 200, {
|
|
1303
|
+
ok: true,
|
|
1304
|
+
terminalId: input.terminalId ?? null,
|
|
1305
|
+
resolvedTo: input.toAgent,
|
|
1306
|
+
deferred: true, // back-compat: a held message is "deferred" to old binaries
|
|
1307
|
+
delivered: false,
|
|
1308
|
+
held: true,
|
|
1309
|
+
reason,
|
|
1310
|
+
mailboxId: row.id,
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* Handle a delayed (`--delay`) send (Spec 1307, re-homed onto the Spec 1313 mailbox; round 3
|
|
1315
|
+
* durable rework). The row is RESOLVED, authorized (the builder-spoofing check inside
|
|
1316
|
+
* `resolveTarget`), formatted, and PERSISTED here at REQUEST time with `not_before = now +
|
|
1317
|
+
* delay*1000` — the security property the immediate path documents (a delayed send must not
|
|
1318
|
+
* defer an authorization check past the conditions that would fail it) is preserved, and only
|
|
1319
|
+
* DELIVERY is deferred. Persisting at request time is what makes `--delay` DURABLE across a
|
|
1320
|
+
* Tower restart (the conscious, architect+maintainer-approved reversal of Spec 1307's
|
|
1321
|
+
* drop-on-restart semantics): the render gate still guarantees a post-restart delivery only
|
|
1322
|
+
* ever lands on a verified-empty prompt, and a pre-due row is visible/cancellable in
|
|
1323
|
+
* `afx inbox`.
|
|
1324
|
+
*
|
|
1325
|
+
* Resolution is live-first then registry (a known agent with no live PTY) so a
|
|
1326
|
+
* dead/unwritable/registry-only target schedules UNIFORMLY — a delayed send does not require a
|
|
1327
|
+
* live session now. reason is left null: the row is SCHEDULED, not held-for-a-reason; the
|
|
1328
|
+
* drainer re-evaluates liveness at due time.
|
|
1329
|
+
*
|
|
1330
|
+
* The body is NEVER written from here. A normal delayed send is delivered by the gated
|
|
1331
|
+
* backstop drainer once `not_before` passes (≤ one 1.5 s tick after due — the delay is a lower
|
|
1332
|
+
* bound). A delayed `--interrupt` (change 2 reshape) additionally keeps a small in-memory timer
|
|
1333
|
+
* that fires ONLY the Ctrl+C at due time (guarded by `isStillLive` + a re-fetched writable
|
|
1334
|
+
* session, inside the submission lock); the ^C ends the turn and the body then delivers through
|
|
1335
|
+
* the SAME gate every send uses — nothing is marked delivered here, so a #1198 dropped write or
|
|
1336
|
+
* a shutdown during the wait can never falsely report delivery or double-deliver. A restart
|
|
1337
|
+
* during the wait loses only the ^C nudge, never the message.
|
|
1338
|
+
*/
|
|
1339
|
+
function handleDelayedSend(res, ctx, db, params) {
|
|
1340
|
+
const { to, workspace, from, message, raw, noEnter, interrupt, deliverAfter, senderWorkspace } = params;
|
|
1341
|
+
const now = Date.now();
|
|
1342
|
+
const notBefore = now + deliverAfter * 1000;
|
|
1343
|
+
// Resolve to a canonical AGENT — live first, then registry (known agent, no live PTY yet).
|
|
1344
|
+
let workspacePath;
|
|
1345
|
+
let toAgent;
|
|
1346
|
+
let isArchitectTarget;
|
|
1347
|
+
let terminalId;
|
|
1348
|
+
const live = resolveTarget(to, workspace, from);
|
|
1349
|
+
if (!isResolveError(live)) {
|
|
1350
|
+
const identity = liveTargetIdentity(live);
|
|
1351
|
+
workspacePath = live.workspacePath;
|
|
1352
|
+
toAgent = identity.toAgent;
|
|
1353
|
+
isArchitectTarget = identity.isArchitectTarget;
|
|
1354
|
+
terminalId = live.terminalId;
|
|
1355
|
+
}
|
|
1356
|
+
else if (live.code === 'NOT_FOUND') {
|
|
1357
|
+
const reg = resolveAgentInRegistry(to, workspace, from);
|
|
1358
|
+
if (isResolveError(reg)) {
|
|
1359
|
+
const statusCode = reg.code === 'AMBIGUOUS' ? 409 : reg.code === 'NO_CONTEXT' ? 400 : 404;
|
|
1360
|
+
const errorCode = reg.code === 'NO_CONTEXT' ? 'INVALID_PARAMS' : reg.code;
|
|
1361
|
+
sendJson(res, statusCode, { error: errorCode, message: reg.message });
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
workspacePath = reg.workspacePath;
|
|
1365
|
+
toAgent = reg.agent;
|
|
1366
|
+
isArchitectTarget = reg.kind === 'architect';
|
|
1367
|
+
terminalId = null;
|
|
1368
|
+
}
|
|
1369
|
+
else {
|
|
1370
|
+
const statusCode = live.code === 'AMBIGUOUS' ? 409 : live.code === 'NO_CONTEXT' ? 400 : 404;
|
|
1371
|
+
const errorCode = live.code === 'NO_CONTEXT' ? 'INVALID_PARAMS' : live.code;
|
|
1372
|
+
sendJson(res, statusCode, { error: errorCode, message: live.message });
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
const formattedMessage = formatMessageForTarget(isArchitectTarget, from, message, raw);
|
|
1376
|
+
// Persist NOW, at request time, with the due time. reason=null → SCHEDULED, not stuck.
|
|
1377
|
+
const row = enqueueMailbox(db, {
|
|
1378
|
+
workspacePath,
|
|
1379
|
+
toAgent,
|
|
1380
|
+
body: message,
|
|
1381
|
+
formattedMessage,
|
|
1382
|
+
fromAgent: from ?? null,
|
|
1383
|
+
fromWorkspace: senderWorkspace,
|
|
1384
|
+
noEnter,
|
|
1385
|
+
terminalId,
|
|
1386
|
+
notBefore,
|
|
1387
|
+
});
|
|
1388
|
+
// A new held (scheduled) row appeared → refresh the held-count indicator (Phase 7).
|
|
1389
|
+
ctx.broadcastNotification({ type: 'overview-changed', title: 'Held mail changed', body: 'scheduled' });
|
|
1390
|
+
if (interrupt) {
|
|
1391
|
+
// Change 2 reshape: an in-memory timer that fires ONLY the Ctrl+C at due time. It writes
|
|
1392
|
+
// no message body and marks nothing delivered — the body delivers through the gated
|
|
1393
|
+
// drainer after the ^C ends the turn. Everything the ^C depends on — `isStillLive`
|
|
1394
|
+
// (delayed-send.ts's generation guard), the session's existence, and its writability — is
|
|
1395
|
+
// re-checked INSIDE the submission lock, right before the write, so a shutdown OR a session
|
|
1396
|
+
// teardown/respawn during the lock-wait writes nothing (invariant 2; the directive's
|
|
1397
|
+
// preferred shape). Diagnostics arg falls back to toAgent when the target has no terminal
|
|
1398
|
+
// id yet.
|
|
1399
|
+
scheduleDelayedSend(deliverAfter, terminalId ?? toAgent, (isStillLive) => {
|
|
1400
|
+
if (!isStillLive())
|
|
1401
|
+
return; // shutdown before/at due → drop the ^C nudge (body survives)
|
|
1402
|
+
if (terminalId) {
|
|
1403
|
+
let fired = false;
|
|
1404
|
+
void submitToSession(terminalId, () => {
|
|
1405
|
+
// Re-check EVERYTHING inside the lock: the queued submission can acquire the lock only
|
|
1406
|
+
// AFTER a shutdown or a session teardown/respawn that landed while it waited behind an
|
|
1407
|
+
// in-flight write. Re-fetch the session and re-check live + writable here; bail with no
|
|
1408
|
+
// ^C otherwise (the body still delivers via the gate).
|
|
1409
|
+
if (!isStillLive())
|
|
1410
|
+
return 0;
|
|
1411
|
+
const live = getTerminalManager().getSession(terminalId);
|
|
1412
|
+
if (!live || !live.writable)
|
|
1413
|
+
return 0;
|
|
1414
|
+
live.write('\x03'); // Ctrl+C only — end the turn; body follows via the gate
|
|
1415
|
+
fired = true;
|
|
1416
|
+
return 0; // no body, no Enter on this path
|
|
1417
|
+
})
|
|
1418
|
+
.then(() => ctx.log('INFO', fired
|
|
1419
|
+
? `Delayed interrupt ^C fired → ${toAgent} (terminal ${terminalId.slice(0, 8)}...); body delivers via the gate`
|
|
1420
|
+
: `Delayed interrupt for ${toAgent}: session not live/writable at write time — body delivers via the gate on the next clean pass`))
|
|
1421
|
+
.catch((err) => ctx.log('ERROR', `Delayed interrupt ^C failed for ${toAgent}: ${err.message}`));
|
|
1422
|
+
}
|
|
1423
|
+
else {
|
|
1424
|
+
ctx.log('INFO', `Delayed interrupt for ${toAgent}: no live terminal at due time — body delivers via the gate on the next clean pass`);
|
|
1425
|
+
}
|
|
1426
|
+
// Nudge the gated drainer so the now-due body delivers promptly (gated — a spurious
|
|
1427
|
+
// nudge onto a busy/mid-turn screen simply re-holds; the backstop is the ultimate net).
|
|
1428
|
+
void getMailboxDrainer().scheduleDrain(workspacePath, toAgent);
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
// A normal delayed send keeps NO timer: the persisted `not_before` row is delivered by the
|
|
1432
|
+
// gated backstop drainer once due — durable across restart by construction.
|
|
1433
|
+
ctx.log('INFO', `Message scheduled (+${deliverAfter}s): ${from ?? 'unknown'} → ${toAgent} (mailbox ${row.id.slice(0, 8)}...)`);
|
|
1434
|
+
sendJson(res, 200, {
|
|
1435
|
+
ok: true,
|
|
1436
|
+
terminalId,
|
|
1437
|
+
resolvedTo: toAgent,
|
|
1438
|
+
deferred: false,
|
|
1439
|
+
scheduled: true,
|
|
1440
|
+
deliverAfter,
|
|
1441
|
+
mailboxId: row.id,
|
|
1442
|
+
notBefore,
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1164
1445
|
async function handleSend(req, res, ctx) {
|
|
1165
1446
|
const body = await parseJsonBody(req);
|
|
1166
1447
|
// Validate required fields
|
|
@@ -1186,111 +1467,381 @@ async function handleSend(req, res, ctx) {
|
|
|
1186
1467
|
const raw = options.raw === true;
|
|
1187
1468
|
const noEnter = options.noEnter === true;
|
|
1188
1469
|
const interrupt = options.interrupt === true;
|
|
1189
|
-
|
|
1470
|
+
const escape = options.escape === true;
|
|
1471
|
+
// Spec 1307 `--delay` (re-homed onto the mailbox): optional deferred delivery.
|
|
1472
|
+
// Validated here as well as at the CLI boundary — /api/send is a public route, so an
|
|
1473
|
+
// unvalidated value would become a setTimeout that fires instantly (NaN) or never
|
|
1474
|
+
// (Infinity). `escape` cannot be combined with a delay: an ESC interrupts the CURRENT
|
|
1475
|
+
// turn by design, so deferring it is contradictory — refuse rather than silently drop
|
|
1476
|
+
// one of the two (a quietly-dropped delay would look like it worked).
|
|
1477
|
+
let deliverAfter;
|
|
1478
|
+
if (options.deliverAfter !== undefined && options.deliverAfter !== null) {
|
|
1479
|
+
const delayError = validateDelaySeconds(options.deliverAfter);
|
|
1480
|
+
if (delayError) {
|
|
1481
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1482
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: delayError }));
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
if (escape) {
|
|
1486
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1487
|
+
res.end(JSON.stringify({
|
|
1488
|
+
error: 'INVALID_PARAMS',
|
|
1489
|
+
message: 'escape cannot be combined with a delay: an ESC keystroke bypasses buffering by design so that it interrupts the CURRENT turn. Send the ESC now, or send a delayed message without escape.',
|
|
1490
|
+
}));
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
deliverAfter = options.deliverAfter;
|
|
1494
|
+
}
|
|
1495
|
+
const db = getGlobalDb();
|
|
1496
|
+
const senderWorkspace = fromWorkspace ?? workspace ?? 'unknown';
|
|
1497
|
+
// Spec 1313 round 3: a delayed send (`--delay`) is RESOLVED, authorized, formatted, and
|
|
1498
|
+
// PERSISTED here at REQUEST time, then deferred — it does NOT require a live session now,
|
|
1499
|
+
// so it is handled BEFORE the immediate live/dead/unwritable/escape/interrupt branches
|
|
1500
|
+
// below (those would 404/503 a delayed send to a target that has no live PTY yet). Only
|
|
1501
|
+
// DELIVERY is deferred, through the same render gate every send uses; the row's `not_before`
|
|
1502
|
+
// makes the delay durable across a Tower restart.
|
|
1503
|
+
if (deliverAfter !== undefined) {
|
|
1504
|
+
handleDelayedSend(res, ctx, db, {
|
|
1505
|
+
to, workspace, from, message, raw, noEnter, interrupt, deliverAfter, senderWorkspace,
|
|
1506
|
+
});
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
// Resolve the target address against LIVE terminals.
|
|
1190
1510
|
// Spec 755: pass `from` so architect resolution is sender-affinity-aware
|
|
1191
1511
|
// when the sender is a builder. Non-builder senders see unchanged behavior.
|
|
1192
1512
|
const result = resolveTarget(to, workspace, from);
|
|
1513
|
+
// --- Resolution failed against live terminals ---
|
|
1193
1514
|
if (isResolveError(result)) {
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1515
|
+
// Spec 1313 dead-session seam: a NOT_FOUND target may still be a KNOWN agent
|
|
1516
|
+
// with no live PTY (e.g. registered in global.db while Tower restarts). Hold
|
|
1517
|
+
// its mail instead of 404ing. escape/interrupt act on a live session only, so
|
|
1518
|
+
// an unresolved target keeps the original error for them.
|
|
1519
|
+
if (result.code === 'NOT_FOUND' && !escape && !interrupt) {
|
|
1520
|
+
const reg = resolveAgentInRegistry(to, workspace, from);
|
|
1521
|
+
if (!isResolveError(reg)) {
|
|
1522
|
+
holdAndRespond(res, ctx, {
|
|
1523
|
+
workspacePath: reg.workspacePath,
|
|
1524
|
+
toAgent: reg.agent,
|
|
1525
|
+
body: message,
|
|
1526
|
+
formattedMessage: formatMessageForTarget(reg.kind === 'architect', from, message, raw),
|
|
1527
|
+
fromAgent: from ?? null,
|
|
1528
|
+
fromWorkspace: senderWorkspace,
|
|
1529
|
+
noEnter,
|
|
1530
|
+
terminalId: null,
|
|
1531
|
+
}, 'no-live-pty');
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
if (reg.code === 'AMBIGUOUS') {
|
|
1535
|
+
sendJson(res, 409, { error: 'AMBIGUOUS', message: reg.message });
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
// else: fall through to the original live-resolution error below.
|
|
1539
|
+
}
|
|
1540
|
+
const statusCode = result.code === 'AMBIGUOUS' ? 409 : result.code === 'NO_CONTEXT' ? 400 : 404;
|
|
1197
1541
|
// Map NO_CONTEXT to INVALID_PARAMS per plan's error contract
|
|
1198
1542
|
const errorCode = result.code === 'NO_CONTEXT' ? 'INVALID_PARAMS' : result.code;
|
|
1199
|
-
res
|
|
1200
|
-
res.end(JSON.stringify({ error: errorCode, message: result.message }));
|
|
1543
|
+
sendJson(res, statusCode, { error: errorCode, message: result.message });
|
|
1201
1544
|
return;
|
|
1202
1545
|
}
|
|
1203
|
-
//
|
|
1546
|
+
// --- Live target resolved; locate its session ---
|
|
1204
1547
|
const manager = getTerminalManager();
|
|
1205
1548
|
const session = manager.getSession(result.terminalId);
|
|
1549
|
+
const { toAgent, isArchitectTarget } = liveTargetIdentity(result);
|
|
1550
|
+
// Dead session: the routing entry resolved but the PTY is gone (exited > 30s).
|
|
1551
|
+
// Hold a normal message; escape/interrupt need a live session → original 404.
|
|
1206
1552
|
if (!session) {
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1553
|
+
if (escape || interrupt) {
|
|
1554
|
+
sendJson(res, 404, {
|
|
1555
|
+
error: 'NOT_FOUND',
|
|
1556
|
+
message: `Terminal session ${result.terminalId} not found (agent '${result.agent}' resolved but terminal is gone).`,
|
|
1557
|
+
});
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
holdAndRespond(res, ctx, {
|
|
1561
|
+
workspacePath: result.workspacePath,
|
|
1562
|
+
toAgent,
|
|
1563
|
+
body: message,
|
|
1564
|
+
formattedMessage: formatMessageForTarget(isArchitectTarget, from, message, raw),
|
|
1565
|
+
fromAgent: from ?? null,
|
|
1566
|
+
fromWorkspace: senderWorkspace,
|
|
1567
|
+
noEnter,
|
|
1568
|
+
terminalId: result.terminalId,
|
|
1569
|
+
}, 'no-live-pty');
|
|
1212
1570
|
return;
|
|
1213
1571
|
}
|
|
1214
1572
|
// #1198: a session whose shellper connection died still reports status
|
|
1215
|
-
// 'running', but every write
|
|
1216
|
-
//
|
|
1573
|
+
// 'running', but every write is dropped. Hold a normal message (it delivers
|
|
1574
|
+
// when the connection recovers); keep the loud 503 for escape/interrupt, which
|
|
1575
|
+
// are operator actions that require the live PTY here and now.
|
|
1217
1576
|
if (!session.writable) {
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1577
|
+
if (escape || interrupt) {
|
|
1578
|
+
ctx.log('ERROR', `Interrupt/ESC not deliverable: ${from ?? 'unknown'} → ${result.agent} (terminal not writable, shellper connection down)`);
|
|
1579
|
+
sendJson(res, 503, {
|
|
1580
|
+
error: 'TERMINAL_NOT_WRITABLE',
|
|
1581
|
+
message: `Terminal for '${result.agent}' is not accepting input (its process connection is down). Retry shortly; if this persists, check Tower logs.`,
|
|
1582
|
+
});
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
holdAndRespond(res, ctx, {
|
|
1586
|
+
workspacePath: result.workspacePath,
|
|
1587
|
+
toAgent,
|
|
1588
|
+
body: message,
|
|
1589
|
+
formattedMessage: formatMessageForTarget(isArchitectTarget, from, message, raw),
|
|
1590
|
+
fromAgent: from ?? null,
|
|
1591
|
+
fromWorkspace: senderWorkspace,
|
|
1592
|
+
noEnter,
|
|
1593
|
+
terminalId: result.terminalId,
|
|
1594
|
+
}, 'no-live-pty');
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
// --- Live, writable session ---
|
|
1598
|
+
// Spec 1273: `escape` delivers a bare ESC keystroke and returns. Explicit human
|
|
1599
|
+
// bypass — no gate, no mailbox row. ESC ends the running turn so already-queued
|
|
1600
|
+
// messages process; the trailing Enter (default) is what lets them through
|
|
1601
|
+
// (matching the verified recovery `afx send <b> --raw "$(printf '\x1b')"`).
|
|
1602
|
+
if (escape) {
|
|
1603
|
+
// Awaited: the response must not claim delivery before the ESC and its
|
|
1604
|
+
// Enter have actually been written (Spec 1273 verify).
|
|
1605
|
+
await submitToSession(result.terminalId, () => writeEscapeToSession(session, noEnter));
|
|
1606
|
+
broadcastMessage({
|
|
1607
|
+
type: 'message',
|
|
1608
|
+
from: { project: path.basename(senderWorkspace), agent: from ?? 'unknown' },
|
|
1609
|
+
to: { project: path.basename(result.workspacePath), agent: toAgent },
|
|
1610
|
+
content: '<ESC>',
|
|
1611
|
+
metadata: { raw: true, source: 'api', escape: true },
|
|
1612
|
+
timestamp: new Date().toISOString(),
|
|
1613
|
+
});
|
|
1614
|
+
ctx.log('INFO', `Interrupt (ESC) sent: ${from ?? 'unknown'} → ${toAgent} (terminal ${result.terminalId.slice(0, 8)}...)`);
|
|
1615
|
+
sendJson(res, 200, { ok: true, terminalId: result.terminalId, resolvedTo: toAgent, deferred: false });
|
|
1224
1616
|
return;
|
|
1225
1617
|
}
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
else if (!isArchitectTarget) {
|
|
1234
|
-
// Architect → Builder (or any → builder)
|
|
1235
|
-
formattedMessage = formatArchitectMessage(message, undefined, raw);
|
|
1236
|
-
}
|
|
1237
|
-
else {
|
|
1238
|
-
// Unknown sender to architect — use raw
|
|
1239
|
-
formattedMessage = raw ? message : formatArchitectMessage(message, undefined, false);
|
|
1240
|
-
}
|
|
1241
|
-
// Build broadcast payload (used for both immediate and deferred delivery)
|
|
1242
|
-
const senderWorkspace = fromWorkspace ?? workspace ?? 'unknown';
|
|
1243
|
-
const broadcastPayload = {
|
|
1244
|
-
type: 'message',
|
|
1245
|
-
from: {
|
|
1246
|
-
project: path.basename(senderWorkspace),
|
|
1247
|
-
agent: from ?? 'unknown',
|
|
1248
|
-
},
|
|
1249
|
-
to: {
|
|
1250
|
-
project: path.basename(result.workspacePath),
|
|
1251
|
-
agent: result.agent,
|
|
1252
|
-
},
|
|
1253
|
-
content: message,
|
|
1254
|
-
metadata: { raw, source: 'api' },
|
|
1255
|
-
timestamp: new Date().toISOString(),
|
|
1256
|
-
};
|
|
1257
|
-
const logMessage = `Message sent: ${from ?? 'unknown'} → ${result.agent} (terminal ${result.terminalId.slice(0, 8)}...)`;
|
|
1258
|
-
// Optionally interrupt first — bypass buffering entirely
|
|
1618
|
+
const formattedMessage = formatMessageForTarget(isArchitectTarget, from, message, raw);
|
|
1619
|
+
// NB: `--delay` (deliverAfter) is handled earlier by handleDelayedSend, before this
|
|
1620
|
+
// immediate-path block — a delayed send is resolved + persisted at request time and does
|
|
1621
|
+
// not fall through here.
|
|
1622
|
+
// Spec 1313: `interrupt` is the explicit human bypass. Ctrl+C, then deliver
|
|
1623
|
+
// WITHOUT the render-gate (the operator is looking at this terminal). A row is
|
|
1624
|
+
// still persisted and marked delivered for audit parity — every send is a row.
|
|
1259
1625
|
if (interrupt) {
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
// Defer only when user has typed recently (within idle threshold).
|
|
1265
|
-
// Bugfix #492: removed session.composing check — composing gets stuck true
|
|
1266
|
-
// after non-Enter keystrokes (Ctrl+C, arrows, Tab), causing 60s delays.
|
|
1267
|
-
const shouldDefer = !interrupt && !session.isUserIdle(sendBuffer.idleThresholdMs);
|
|
1268
|
-
if (shouldDefer) {
|
|
1269
|
-
// User is actively typing — buffer for deferred delivery
|
|
1270
|
-
sendBuffer.enqueue({
|
|
1271
|
-
sessionId: result.terminalId,
|
|
1626
|
+
const row = enqueueMailbox(db, {
|
|
1627
|
+
workspacePath: result.workspacePath,
|
|
1628
|
+
toAgent,
|
|
1629
|
+
body: message,
|
|
1272
1630
|
formattedMessage,
|
|
1631
|
+
fromAgent: from ?? null,
|
|
1632
|
+
fromWorkspace: senderWorkspace,
|
|
1273
1633
|
noEnter,
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1634
|
+
terminalId: result.terminalId,
|
|
1635
|
+
});
|
|
1636
|
+
// Claim the row as delivered SYNCHRONOUSLY, before any await (CMAP round 2 — Codex): the
|
|
1637
|
+
// interrupt writes the message itself (a gate bypass), so the row must never be visible to
|
|
1638
|
+
// the mailbox drainer as `held`, or a concurrent backstop/scheduleDrain pass could gate-deliver
|
|
1639
|
+
// the SAME row and put the bytes on the wire twice. enqueue→markDelivered are both synchronous
|
|
1640
|
+
// (no await between), so there is no window for the drainer to pick it up before we own it.
|
|
1641
|
+
// Tradeoff (CMAP round 3 — Codex/Claude): claiming BEFORE the write below means that if
|
|
1642
|
+
// submitToSession throws/crashes, the row reads `delivered` for audit though no bytes reached
|
|
1643
|
+
// the PTY — the message is lost, not retried. This is the deliberate choice: the write is not
|
|
1644
|
+
// transactional, so a partial write may already have put bytes on the wire, and re-holding (the
|
|
1645
|
+
// alternative) would let the backstop gate-deliver a SECOND copy. Losing a crashed interrupt is
|
|
1646
|
+
// preferred over double-delivering it; `--interrupt` is the explicit human gate-bypass anyway.
|
|
1647
|
+
markMailboxDelivered(db, row.id);
|
|
1648
|
+
// Deliver the interrupt as ONE atomic critical section under the Spec 1273 per-terminal
|
|
1649
|
+
// submission lock (CMAP round 1 — Gemini/Codex/Claude): the Ctrl+C, its 100 ms settle, and
|
|
1650
|
+
// the message write all occur inside a single lock acquisition. Previously the \x03 + the
|
|
1651
|
+
// settle sat OUTSIDE the lock, so a concurrent submission to the same terminal could land
|
|
1652
|
+
// its Ctrl+C inside another submission's text→Enter window (killing that composer) or run
|
|
1653
|
+
// during the 100 ms gap. `writeMessageToSession(..., 100)` schedules the text 100 ms after
|
|
1654
|
+
// the ^C (the settle) and returns the completion offset, so the lock is held until the
|
|
1655
|
+
// whole interrupt is on the wire; uncontended, it runs at once.
|
|
1656
|
+
// Scope of the guarantee: this serializes interrupt against interrupt/escape — the only
|
|
1657
|
+
// /api/send writers that take this per-terminal lock. It does NOT serialize against a
|
|
1658
|
+
// concurrent mailbox/backstop delivery (which writes through the per-AGENT serializer, a
|
|
1659
|
+
// disjoint lock); interrupt is the explicit gate-bypassing human action, and closing that
|
|
1660
|
+
// cross-path race would require the mailbox write edge to take this lock too (a separate,
|
|
1661
|
+
// larger change — flagged, not done here).
|
|
1662
|
+
await submitToSession(result.terminalId, () => {
|
|
1663
|
+
session.write('\x03'); // Ctrl+C
|
|
1664
|
+
return writeMessageToSession(session, formattedMessage, noEnter, 100);
|
|
1665
|
+
});
|
|
1666
|
+
broadcastMessage({
|
|
1667
|
+
type: 'message',
|
|
1668
|
+
from: { project: path.basename(senderWorkspace), agent: from ?? 'unknown' },
|
|
1669
|
+
to: { project: path.basename(result.workspacePath), agent: toAgent },
|
|
1670
|
+
content: message,
|
|
1671
|
+
metadata: { raw, source: 'api' },
|
|
1672
|
+
timestamp: new Date().toISOString(),
|
|
1673
|
+
});
|
|
1674
|
+
ctx.log('INFO', `Message delivered (interrupt): ${from ?? 'unknown'} → ${toAgent} (terminal ${result.terminalId.slice(0, 8)}...)`);
|
|
1675
|
+
sendJson(res, 200, {
|
|
1676
|
+
ok: true,
|
|
1677
|
+
terminalId: result.terminalId,
|
|
1678
|
+
resolvedTo: toAgent,
|
|
1679
|
+
deferred: false,
|
|
1680
|
+
delivered: true,
|
|
1681
|
+
held: false,
|
|
1682
|
+
mailboxId: row.id,
|
|
1683
|
+
reason: null,
|
|
1277
1684
|
});
|
|
1278
|
-
|
|
1685
|
+
return;
|
|
1279
1686
|
}
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1687
|
+
// Spec 1313 normal path: PERSIST first (survives a crash), then attempt gated
|
|
1688
|
+
// delivery through the single serialized path. The response reports the row's
|
|
1689
|
+
// real first outcome — a clean, render-verified empty prompt delivers now;
|
|
1690
|
+
// anything else (busy/menu/wrapper/no-profile) stays held for the backstop.
|
|
1691
|
+
const row = enqueueMailbox(db, {
|
|
1692
|
+
workspacePath: result.workspacePath,
|
|
1693
|
+
toAgent,
|
|
1694
|
+
body: message,
|
|
1695
|
+
formattedMessage,
|
|
1696
|
+
fromAgent: from ?? null,
|
|
1697
|
+
fromWorkspace: senderWorkspace,
|
|
1698
|
+
noEnter,
|
|
1699
|
+
terminalId: result.terminalId,
|
|
1700
|
+
});
|
|
1701
|
+
// Deliver to the session THIS request already resolved rather than re-resolving
|
|
1702
|
+
// by agent: the base resolver would repeat the routing-map lookup (redundant) and
|
|
1703
|
+
// could target a different terminal if the map changed mid-request. The backstop
|
|
1704
|
+
// drainer, which only has the agent, still uses the base resolver.
|
|
1705
|
+
const basePorts = makeDeliveryPorts(ctx.log);
|
|
1706
|
+
const ports = {
|
|
1707
|
+
...basePorts,
|
|
1708
|
+
getSessionForAgent: (ws, agent) => ws === result.workspacePath && agent === toAgent ? session : basePorts.getSessionForAgent(ws, agent),
|
|
1709
|
+
};
|
|
1710
|
+
try {
|
|
1711
|
+
await deliverAgentMailSerialized(ports, db, result.workspacePath, toAgent);
|
|
1286
1712
|
}
|
|
1287
|
-
|
|
1288
|
-
|
|
1713
|
+
catch (err) {
|
|
1714
|
+
// A gate/write error leaves the row HELD (markDelivered only runs on a
|
|
1715
|
+
// completed write); the backstop drainer will retry. Report held, not a 500.
|
|
1716
|
+
ctx.log('ERROR', `Delivery attempt errored for ${toAgent} (row ${row.id.slice(0, 8)}... stays held): ${err.message}`);
|
|
1717
|
+
}
|
|
1718
|
+
const stored = getMailboxById(db, row.id);
|
|
1719
|
+
if (stored?.status === 'delivered') {
|
|
1720
|
+
ctx.log('INFO', `Message delivered: ${from ?? 'unknown'} → ${toAgent} (terminal ${result.terminalId.slice(0, 8)}...)`);
|
|
1721
|
+
sendJson(res, 200, {
|
|
1722
|
+
ok: true,
|
|
1723
|
+
terminalId: result.terminalId,
|
|
1724
|
+
resolvedTo: toAgent,
|
|
1725
|
+
deferred: false,
|
|
1726
|
+
delivered: true,
|
|
1727
|
+
held: false,
|
|
1728
|
+
mailboxId: row.id,
|
|
1729
|
+
reason: null,
|
|
1730
|
+
});
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
const reason = stored?.reason ?? 'busy';
|
|
1734
|
+
ctx.log('INFO', `Message held (${reason}): ${from ?? 'unknown'} → ${toAgent} (mailbox ${row.id.slice(0, 8)}...)`);
|
|
1735
|
+
// The message stayed held → a new held row is in the set; refresh the indicator
|
|
1736
|
+
// count (Spec 1313, Phase 7). The delivered branch above needs no fire — the
|
|
1737
|
+
// delivery path's onHeldStateChange already broadcast when the row left the set.
|
|
1738
|
+
ctx.broadcastNotification({ type: 'overview-changed', title: 'Held mail changed', body: `held ${reason}` });
|
|
1739
|
+
sendJson(res, 200, {
|
|
1289
1740
|
ok: true,
|
|
1290
1741
|
terminalId: result.terminalId,
|
|
1291
|
-
resolvedTo:
|
|
1292
|
-
deferred:
|
|
1742
|
+
resolvedTo: toAgent,
|
|
1743
|
+
deferred: true,
|
|
1744
|
+
delivered: false,
|
|
1745
|
+
held: true,
|
|
1746
|
+
reason,
|
|
1747
|
+
mailboxId: row.id,
|
|
1748
|
+
});
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* GET /api/inbox — list held (undelivered) mailbox rows for a workspace. Backs the
|
|
1752
|
+
* workspace-scoped `afx inbox` (Spec 1313 decision 8): `?workspace=<path>` selects the
|
|
1753
|
+
* workspace (the CLI passes the current one by default); the path is normalized to the
|
|
1754
|
+
* same realpath form the enqueue path stores, so a raw workspace root still matches its
|
|
1755
|
+
* held rows. Omitting `?workspace=` lists every workspace — an API-level convenience the
|
|
1756
|
+
* CLI never triggers, kept for direct callers. Metadata-only projection (Spec 1313
|
|
1757
|
+
* redaction rule): id, addresses, why-held reason, escalation flag, and enqueue time —
|
|
1758
|
+
* the message BODY is deliberately never surfaced here (it travels only over the live
|
|
1759
|
+
* terminal stream on delivery). `escalated` is normalized from SQLite's 0/1 to a bool.
|
|
1760
|
+
*/
|
|
1761
|
+
function handleInboxList(res, url) {
|
|
1762
|
+
const rawWorkspace = url.searchParams.get('workspace');
|
|
1763
|
+
// Normalize to the stored realpath key (mailbox workspace_path is normalized at
|
|
1764
|
+
// enqueue — tower-routes handleSend / holdAndRespond — matching overview.ts). Without
|
|
1765
|
+
// this a symlinked workspace root would miss its own held rows.
|
|
1766
|
+
const workspace = rawWorkspace ? normalizeWorkspacePath(rawWorkspace) : undefined;
|
|
1767
|
+
const rows = listHeldMailbox(getGlobalDb(), workspace);
|
|
1768
|
+
const projected = rows.map((r) => ({
|
|
1769
|
+
id: r.id,
|
|
1770
|
+
workspacePath: r.workspace_path,
|
|
1771
|
+
toAgent: r.to_agent,
|
|
1772
|
+
fromAgent: r.from_agent,
|
|
1773
|
+
reason: r.reason,
|
|
1774
|
+
escalated: r.escalated === 1,
|
|
1775
|
+
createdAt: r.created_at,
|
|
1776
|
+
// Spec 1313 round 3: due time of a pre-due delayed (`--delay`) row; null = deliver-ASAP.
|
|
1777
|
+
// The CLI renders "in Ns" for a row whose notBefore is still in the future.
|
|
1778
|
+
notBefore: r.not_before,
|
|
1293
1779
|
}));
|
|
1780
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1781
|
+
res.end(JSON.stringify(projected));
|
|
1782
|
+
}
|
|
1783
|
+
/**
|
|
1784
|
+
* POST /api/inbox/:id/dismiss — mark a held row `dismissed` (operator-cleared via
|
|
1785
|
+
* `afx inbox dismiss`). Soft transition: the row is marked, not deleted, and NEVER
|
|
1786
|
+
* delivered. The dispatch matches this path for ANY method, so the method is guarded
|
|
1787
|
+
* here: a non-POST request (e.g. GET) must not mutate state → 405. 404 when the id names
|
|
1788
|
+
* no currently-held row (already terminal or unknown), so the CLI reports a clean error.
|
|
1789
|
+
* On success, fires `overview-changed` so the held-count indicator drops immediately.
|
|
1790
|
+
* Authorized at the workspace-human trust level — any local operator may dismiss any held
|
|
1791
|
+
* row (Spec 1313 decision 8); no ownership check.
|
|
1792
|
+
*/
|
|
1793
|
+
function handleInboxDismiss(req, res, ctx, match) {
|
|
1794
|
+
// Dismissal mutates state — only POST may reach it. The path match in the dispatch is
|
|
1795
|
+
// method-agnostic, so without this guard a GET (or any method) to this URL would dismiss
|
|
1796
|
+
// mail. Matches the method-guard convention used by the cron action routes.
|
|
1797
|
+
if (req.method !== 'POST') {
|
|
1798
|
+
sendJson(res, 405, { error: 'Method not allowed' });
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
const id = decodeURIComponent(match[1]);
|
|
1802
|
+
if (!dismissMailbox(getGlobalDb(), id)) {
|
|
1803
|
+
sendJson(res, 404, { error: 'NOT_FOUND', message: `No held message with id '${id}'` });
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
ctx.broadcastNotification({ type: 'overview-changed', title: 'Held mail changed', body: 'dismissed' });
|
|
1807
|
+
ctx.log('INFO', `Inbox: dismissed held message ${id.slice(0, 8)}...`);
|
|
1808
|
+
sendJson(res, 200, { ok: true });
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* GET /api/inbox/:id — return a single mailbox row INCLUDING its body. Backs
|
|
1812
|
+
* `afx inbox show <id>` (Spec 1313 §178: `afx inbox` is a UI surface that legitimately
|
|
1813
|
+
* displays message bodies over the local Tower connection — the redaction rule applies to
|
|
1814
|
+
* logs/diagnostics/telemetry only, never this view). Mirrors dismiss's addressing model:
|
|
1815
|
+
* by unique id at the workspace-human trust level, no per-recipient/workspace ownership
|
|
1816
|
+
* check (decision 8). GET-only (the path match in the dispatch is method-agnostic, so a
|
|
1817
|
+
* non-GET is rejected here); 404 when the id names no row. The body is returned to the
|
|
1818
|
+
* caller but never logged.
|
|
1819
|
+
*/
|
|
1820
|
+
function handleInboxShow(req, res, match) {
|
|
1821
|
+
if (req.method !== 'GET') {
|
|
1822
|
+
sendJson(res, 405, { error: 'Method not allowed' });
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
const id = decodeURIComponent(match[1]);
|
|
1826
|
+
const row = getMailboxById(getGlobalDb(), id);
|
|
1827
|
+
if (!row) {
|
|
1828
|
+
sendJson(res, 404, { error: 'NOT_FOUND', message: `No message with id '${id}'` });
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
sendJson(res, 200, {
|
|
1832
|
+
id: row.id,
|
|
1833
|
+
workspacePath: row.workspace_path,
|
|
1834
|
+
toAgent: row.to_agent,
|
|
1835
|
+
fromAgent: row.from_agent,
|
|
1836
|
+
fromWorkspace: row.from_workspace,
|
|
1837
|
+
status: row.status,
|
|
1838
|
+
reason: row.reason,
|
|
1839
|
+
escalated: row.escalated === 1,
|
|
1840
|
+
body: row.body,
|
|
1841
|
+
createdAt: row.created_at,
|
|
1842
|
+
notBefore: row.not_before,
|
|
1843
|
+
resolvedAt: row.resolved_at,
|
|
1844
|
+
});
|
|
1294
1845
|
}
|
|
1295
1846
|
async function handleBrowse(res, url) {
|
|
1296
1847
|
const inputPath = url.searchParams.get('path') || '';
|
|
@@ -1854,6 +2405,13 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
|
|
|
1854
2405
|
const session = manager.createSessionRaw({
|
|
1855
2406
|
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
1856
2407
|
cwd: workspacePath,
|
|
2408
|
+
// Spec 1313: thread/persist for reconstruction symmetry with the other
|
|
2409
|
+
// createSessionRaw sites. A workspace-root shell resolves to no-profile
|
|
2410
|
+
// (its command is a shell, not an agent, and the cwd has no launch script),
|
|
2411
|
+
// so `afx send` correctly holds. (A shell whose cwd happened to be a builder
|
|
2412
|
+
// worktree would resolve that worktree's harness via the launch-script fallback.)
|
|
2413
|
+
command: shellCmd,
|
|
2414
|
+
args: shellArgs,
|
|
1857
2415
|
});
|
|
1858
2416
|
const ptySession = manager.getSession(session.id);
|
|
1859
2417
|
if (ptySession) {
|
|
@@ -1861,7 +2419,7 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
|
|
|
1861
2419
|
}
|
|
1862
2420
|
const entry = getWorkspaceTerminalsEntry(workspacePath);
|
|
1863
2421
|
entry.shells.set(shellId, session.id);
|
|
1864
|
-
saveTerminalSession(session.id, workspacePath, 'shell', shellId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, session.label, workspacePath);
|
|
2422
|
+
saveTerminalSession(session.id, workspacePath, 'shell', shellId, shellperInfo.pid, shellperInfo.socketPath, shellperInfo.pid, shellperInfo.startTime, session.label, workspacePath, shellCmd);
|
|
1865
2423
|
shellCreated = true;
|
|
1866
2424
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1867
2425
|
res.end(JSON.stringify({
|
|
@@ -1890,7 +2448,7 @@ async function handleWorkspaceShellCreate(res, ctx, workspacePath) {
|
|
|
1890
2448
|
});
|
|
1891
2449
|
const entry = getWorkspaceTerminalsEntry(workspacePath);
|
|
1892
2450
|
entry.shells.set(shellId, session.id);
|
|
1893
|
-
saveTerminalSession(session.id, workspacePath, 'shell', shellId, session.pid, null, null, null, session.label, workspacePath);
|
|
2451
|
+
saveTerminalSession(session.id, workspacePath, 'shell', shellId, session.pid, null, null, null, session.label, workspacePath, shellCmd);
|
|
1894
2452
|
ctx.log('WARN', `Shell ${shellId} for ${workspacePath} is non-persistent (shellper unavailable)`);
|
|
1895
2453
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1896
2454
|
res.end(JSON.stringify({
|
|
@@ -2143,6 +2701,13 @@ async function handleWorkspaceTabDelete(res, ctx, workspacePath, tabId) {
|
|
|
2143
2701
|
return;
|
|
2144
2702
|
}
|
|
2145
2703
|
if (terminalId) {
|
|
2704
|
+
// Issue #1261: this path discards the kill result and answers 204 either
|
|
2705
|
+
// way, so a not-yet-wired Tower would report a successful close for a
|
|
2706
|
+
// terminal it never touched. Refuse instead.
|
|
2707
|
+
if (!instancesReady()) {
|
|
2708
|
+
respondStartingUp(res);
|
|
2709
|
+
return;
|
|
2710
|
+
}
|
|
2146
2711
|
// Disable shellper auto-restart if applicable, then kill the PtySession
|
|
2147
2712
|
await killTerminalWithShellper(manager, terminalId);
|
|
2148
2713
|
// TICK-001: Delete from SQLite
|