@songsid/agend 2.3.0-beta.9 → 2.3.1
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/README.md +16 -8
- package/README.zh-TW.md +16 -8
- package/dist/adapter-world.js.map +1 -1
- package/dist/agend-home.d.ts +5 -0
- package/dist/agend-home.js +7 -0
- package/dist/agend-home.js.map +1 -0
- package/dist/agent-cli-instructions.md +7 -1
- package/dist/agent-cli.js +41 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +9 -0
- package/dist/agent-endpoint.js +23 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-file-read.js +1 -1
- package/dist/agent-file-read.js.map +1 -1
- package/dist/agent-whats-new.d.ts +16 -0
- package/dist/agent-whats-new.js +53 -12
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/apply-job.js.map +1 -1
- package/dist/atomic-write.js.map +1 -1
- package/dist/auth-api.d.ts +12 -0
- package/dist/auth-api.js +77 -22
- package/dist/auth-api.js.map +1 -1
- package/dist/authority/authority-state.d.ts +4 -0
- package/dist/authority/authority-state.js +17 -8
- package/dist/authority/authority-state.js.map +1 -1
- package/dist/authority/session-authorizations.js.map +1 -1
- package/dist/authority/web-authority.d.ts +16 -8
- package/dist/authority/web-authority.js +19 -5
- package/dist/authority/web-authority.js.map +1 -1
- package/dist/backend/antigravity.d.ts +2 -2
- package/dist/backend/antigravity.js +2 -1
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.js +13 -4
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex-metadata.d.ts +8 -0
- package/dist/backend/codex-metadata.js +74 -17
- package/dist/backend/codex-metadata.js.map +1 -1
- package/dist/backend/codex-package.d.ts +30 -0
- package/dist/backend/codex-package.js +133 -0
- package/dist/backend/codex-package.js.map +1 -0
- package/dist/backend/codex-session-lookup.js.map +1 -1
- package/dist/backend/codex.d.ts +2 -0
- package/dist/backend/codex.js +25 -5
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/credential-profile.d.ts +2 -0
- package/dist/backend/credential-profile.js +2 -0
- package/dist/backend/credential-profile.js.map +1 -1
- package/dist/backend/effort-metadata.js.map +1 -1
- package/dist/backend/grok.js +58 -3
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro-engine-ledger.js.map +1 -1
- package/dist/backend/kiro-kas-store.js.map +1 -1
- package/dist/backend/kiro-v2-store.js.map +1 -1
- package/dist/backend/kiro-v3-identity.js.map +1 -1
- package/dist/backend/kiro.d.ts +9 -1
- package/dist/backend/kiro.js +102 -1
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/launch-effort.js.map +1 -1
- package/dist/backend/mock.js.map +1 -1
- package/dist/backend/muse.d.ts +20 -2
- package/dist/backend/muse.js +154 -16
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/opencode-permission.js +1 -1
- package/dist/backend/opencode-permission.js.map +1 -1
- package/dist/backend/opencode.js +2 -1
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/session-signals.js.map +1 -1
- package/dist/backend-availability.js.map +1 -1
- package/dist/backend-command.d.ts +2 -0
- package/dist/backend-command.js +3 -1
- package/dist/backend-command.js.map +1 -1
- package/dist/backend-outage.js.map +1 -1
- package/dist/binary-probe.js +1 -1
- package/dist/binary-probe.js.map +1 -1
- package/dist/bug-report-cli.d.ts +30 -0
- package/dist/bug-report-cli.js +139 -0
- package/dist/bug-report-cli.js.map +1 -0
- package/dist/bug-report.d.ts +47 -0
- package/dist/bug-report.js +137 -0
- package/dist/bug-report.js.map +1 -0
- package/dist/bundled-skills.d.ts +18 -0
- package/dist/bundled-skills.js +38 -0
- package/dist/bundled-skills.js.map +1 -0
- package/dist/cache-service.js +1 -1
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/adapters/discord-client-retirement.d.ts +5 -0
- package/dist/channel/adapters/discord-client-retirement.js +57 -0
- package/dist/channel/adapters/discord-client-retirement.js.map +1 -0
- package/dist/channel/adapters/discord.d.ts +4 -0
- package/dist/channel/adapters/discord.js +57 -12
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +7 -1
- package/dist/channel/adapters/telegram.js +57 -49
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-server.js +21 -4
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.d.ts +3 -1
- package/dist/channel/mcp-tools.js +41 -4
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/message-queue.js.map +1 -1
- package/dist/channel/sticker-download.js.map +1 -1
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/child-process.d.ts +21 -0
- package/dist/child-process.js +54 -0
- package/dist/child-process.js.map +1 -0
- package/dist/classic-channel-manager.js +3 -2
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli-entry.js.map +1 -1
- package/dist/cli-flags.js.map +1 -1
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +448 -313
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +1 -1
- package/dist/command-table.js +1 -1
- package/dist/command-table.js.map +1 -1
- package/dist/compact-watch.js.map +1 -1
- package/dist/completion-install.js.map +1 -1
- package/dist/completion.js.map +1 -1
- package/dist/config-validator.d.ts +6 -0
- package/dist/config-validator.js +81 -8
- package/dist/config-validator.js.map +1 -1
- package/dist/config.d.ts +15 -3
- package/dist/config.js +41 -10
- package/dist/config.js.map +1 -1
- package/dist/connection-secrets.js.map +1 -1
- package/dist/context-guardian.js.map +1 -1
- package/dist/coredump-filter.js.map +1 -1
- package/dist/cpu-profile.js.map +1 -1
- package/dist/cross-instance-envelope.js.map +1 -1
- package/dist/cross-instance-notice.d.ts +7 -0
- package/dist/cross-instance-notice.js +10 -0
- package/dist/cross-instance-notice.js.map +1 -1
- package/dist/daemon.d.ts +52 -1
- package/dist/daemon.js +385 -27
- package/dist/daemon.js.map +1 -1
- package/dist/daily-summary.js.map +1 -1
- package/dist/darwin-memory.js +1 -1
- package/dist/darwin-memory.js.map +1 -1
- package/dist/dashboard-live.d.ts +8 -0
- package/dist/dashboard-live.js +35 -0
- package/dist/dashboard-live.js.map +1 -0
- package/dist/dashboard-permissions.d.ts +69 -0
- package/dist/dashboard-permissions.js +138 -0
- package/dist/dashboard-permissions.js.map +1 -0
- package/dist/decision-list.js.map +1 -1
- package/dist/delivery-consumed-watch.js.map +1 -1
- package/dist/delivery-outbox.d.ts +11 -0
- package/dist/delivery-outbox.js +52 -2
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-outbox.test.js.map +1 -1
- package/dist/delivery-reconciliation.test.js.map +1 -1
- package/dist/diagnostic-redaction.d.ts +10 -0
- package/dist/diagnostic-redaction.js +167 -0
- package/dist/diagnostic-redaction.js.map +1 -0
- package/dist/doctor.d.ts +8 -0
- package/dist/doctor.js +52 -0
- package/dist/doctor.js.map +1 -1
- package/dist/e2e-isolation.js.map +1 -1
- package/dist/event-log.js.map +1 -1
- package/dist/export-import.js +7 -5
- package/dist/export-import.js.map +1 -1
- package/dist/fleet-context.d.ts +1 -1
- package/dist/fleet-control-audit.js.map +1 -1
- package/dist/fleet-label.js.map +1 -1
- package/dist/fleet-level-config.d.ts +1 -1
- package/dist/fleet-level-config.js +2 -0
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-lock.d.ts +6 -0
- package/dist/fleet-lock.js +22 -2
- package/dist/fleet-lock.js.map +1 -1
- package/dist/fleet-manager.d.ts +283 -15
- package/dist/fleet-manager.js +1825 -331
- package/dist/fleet-manager.js.map +1 -1
- package/dist/fleet-process-handlers.d.ts +18 -0
- package/dist/fleet-process-handlers.js +70 -0
- package/dist/fleet-process-handlers.js.map +1 -0
- package/dist/fleet-startup.d.ts +14 -0
- package/dist/fleet-startup.js +30 -0
- package/dist/fleet-startup.js.map +1 -0
- package/dist/fleet-traffic.js.map +1 -1
- package/dist/forged-envelope-scan.d.ts +0 -20
- package/dist/forged-envelope-scan.js +3 -2
- package/dist/forged-envelope-scan.js.map +1 -1
- package/dist/full-restart.d.ts +5 -5
- package/dist/full-restart.js +5 -5
- package/dist/full-restart.js.map +1 -1
- package/dist/general-knowledge/skills/bug-report/SKILL.md +31 -0
- package/dist/general-knowledge/skills/credential-profiles/SKILL.md +52 -11
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +5 -1
- package/dist/general-knowledge/skills/fleet-config/SKILL.md +3 -1
- package/dist/general-knowledge/skills/fleet-restart/SKILL.md +8 -9
- package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +11 -0
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +1 -1
- package/dist/general-knowledge/skills/scheduling/SKILL.md +4 -0
- package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +321 -0
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +24 -4
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +554 -0
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +156 -32
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
- package/dist/general-knowledge/steering/core-rules.md +1 -1
- package/dist/host-memory.js.map +1 -1
- package/dist/idle-gate-log.d.ts +19 -0
- package/dist/idle-gate-log.js +15 -0
- package/dist/idle-gate-log.js.map +1 -0
- package/dist/instance-config-impact.js +25 -4
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +11 -1
- package/dist/instance-lifecycle.js +97 -25
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +12 -6
- package/dist/instructions.js +19 -10
- package/dist/instructions.js.map +1 -1
- package/dist/kiro-compact-pane.js.map +1 -1
- package/dist/kiro-db-reader.js.map +1 -1
- package/dist/kiro-engine-status.js.map +1 -1
- package/dist/kiro-log-gate.js.map +1 -1
- package/dist/kiro-transcript-lane.js.map +1 -1
- package/dist/locale.js +450 -20
- package/dist/locale.js.map +1 -1
- package/dist/log-service.js +1 -0
- package/dist/log-service.js.map +1 -1
- package/dist/log-steps.d.ts +1 -0
- package/dist/log-steps.js +15 -0
- package/dist/log-steps.js.map +1 -1
- package/dist/logger.js +3 -2
- package/dist/logger.js.map +1 -1
- package/dist/login-controller.d.ts +45 -0
- package/dist/login-controller.js +101 -5
- package/dist/login-controller.js.map +1 -1
- package/dist/login-flows.js +1 -1
- package/dist/login-manager.js.map +1 -1
- package/dist/login-shell.js +3 -1
- package/dist/login-shell.js.map +1 -1
- package/dist/ls-attach-resolve.d.ts +96 -0
- package/dist/ls-attach-resolve.js +180 -0
- package/dist/ls-attach-resolve.js.map +1 -0
- package/dist/ls-rows.d.ts +12 -0
- package/dist/ls-rows.js +83 -4
- package/dist/ls-rows.js.map +1 -1
- package/dist/memory-pressure.js.map +1 -1
- package/dist/muse-usage-relay.js.map +1 -1
- package/dist/needs-you-hub.d.ts +98 -1
- package/dist/needs-you-hub.js +313 -44
- package/dist/needs-you-hub.js.map +1 -1
- package/dist/needs-you-live.d.ts +5 -2
- package/dist/needs-you-live.js +6 -3
- package/dist/needs-you-live.js.map +1 -1
- package/dist/needs-you.d.ts +68 -3
- package/dist/needs-you.js +39 -4
- package/dist/needs-you.js.map +1 -1
- package/dist/network-family.js.map +1 -1
- package/dist/operator-control-paths.js +3 -4
- package/dist/operator-control-paths.js.map +1 -1
- package/dist/outbound-handlers.d.ts +5 -2
- package/dist/outbound-handlers.js +81 -24
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +106 -60
- package/dist/outbound-schemas.js +33 -10
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/outbox-open-error.js.map +1 -1
- package/dist/pane-context-cache.js.map +1 -1
- package/dist/paths.js.map +1 -1
- package/dist/private-dir.js.map +1 -1
- package/dist/probe-worker-pool.js.map +1 -1
- package/dist/profile-control.js.map +1 -1
- package/dist/prompt-file-ref.js.map +1 -1
- package/dist/provider-probe.js.map +1 -1
- package/dist/provider-secret-registry.d.ts +5 -3
- package/dist/provider-secret-registry.js +8 -2
- package/dist/provider-secret-registry.js.map +1 -1
- package/dist/public-link-auto-open.d.ts +32 -0
- package/dist/public-link-auto-open.js +86 -0
- package/dist/public-link-auto-open.js.map +1 -0
- package/dist/public-link-owner.d.ts +10 -0
- package/dist/public-link-owner.js +36 -0
- package/dist/public-link-owner.js.map +1 -0
- package/dist/public-link-progress.d.ts +12 -1
- package/dist/public-link-progress.js +5 -10
- package/dist/public-link-progress.js.map +1 -1
- package/dist/public-preview.js.map +1 -1
- package/dist/public-tunnel-config.d.ts +11 -0
- package/dist/public-tunnel-config.js +68 -0
- package/dist/public-tunnel-config.js.map +1 -0
- package/dist/public-web-gateway.d.ts +2 -0
- package/dist/public-web-gateway.js +19 -6
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/public-web-link.d.ts +48 -3
- package/dist/public-web-link.js +177 -51
- package/dist/public-web-link.js.map +1 -1
- package/dist/quickstart-api.d.ts +9 -0
- package/dist/quickstart-api.js +82 -0
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +5 -0
- package/dist/quickstart.js +40 -25
- package/dist/quickstart.js.map +1 -1
- package/dist/reply-buttons-controller.js.map +1 -1
- package/dist/reply-dedup.js.map +1 -1
- package/dist/resource-report.js +1 -1
- package/dist/resource-report.js.map +1 -1
- package/dist/restart-guard.js.map +1 -1
- package/dist/restart-progress.d.ts +80 -1
- package/dist/restart-progress.js +306 -21
- package/dist/restart-progress.js.map +1 -1
- package/dist/rollout-index.js.map +1 -1
- package/dist/runtime-cpu-profile.js.map +1 -1
- package/dist/scheduler/db.d.ts +40 -13
- package/dist/scheduler/db.js +123 -89
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/scheduler/task-board-text.d.ts +26 -0
- package/dist/scheduler/task-board-text.js +140 -0
- package/dist/scheduler/task-board-text.js.map +1 -0
- package/dist/scheduler/task-board.d.ts +730 -0
- package/dist/scheduler/task-board.js +2179 -0
- package/dist/scheduler/task-board.js.map +1 -0
- package/dist/scheduler/types.d.ts +6 -1
- package/dist/sd-notify.js +2 -1
- package/dist/sd-notify.js.map +1 -1
- package/dist/search/search-docs.d.ts +58 -0
- package/dist/search/search-docs.js +63 -0
- package/dist/search/search-docs.js.map +1 -0
- package/dist/search/search-index.d.ts +149 -0
- package/dist/search/search-index.js +453 -0
- package/dist/search/search-index.js.map +1 -0
- package/dist/search/search-store.d.ts +122 -0
- package/dist/search/search-store.js +236 -0
- package/dist/search/search-store.js.map +1 -0
- package/dist/search/search-worker.d.ts +1 -0
- package/dist/search/search-worker.js +163 -0
- package/dist/search/search-worker.js.map +1 -0
- package/dist/secret-file.js.map +1 -1
- package/dist/secret-store.d.ts +2 -0
- package/dist/secret-store.js +21 -0
- package/dist/secret-store.js.map +1 -1
- package/dist/service-installer.d.ts +98 -1
- package/dist/service-installer.js +246 -11
- package/dist/service-installer.js.map +1 -1
- package/dist/service-owner.d.ts +44 -0
- package/dist/service-owner.js +48 -0
- package/dist/service-owner.js.map +1 -0
- package/dist/service-plan.d.ts +39 -2
- package/dist/service-plan.js +188 -6
- package/dist/service-plan.js.map +1 -1
- package/dist/session-oom-stop.d.ts +45 -0
- package/dist/session-oom-stop.js +259 -0
- package/dist/session-oom-stop.js.map +1 -0
- package/dist/session-oom.d.ts +34 -0
- package/dist/session-oom.js +251 -0
- package/dist/session-oom.js.map +1 -0
- package/dist/settings-api.d.ts +14 -1
- package/dist/settings-api.js +74 -13
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-baseline.js.map +1 -1
- package/dist/settings-change.d.ts +5 -0
- package/dist/settings-change.js +167 -6
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-confirmation.d.ts +9 -1
- package/dist/settings-confirmation.js +18 -8
- package/dist/settings-confirmation.js.map +1 -1
- package/dist/settings-control.js +2 -1
- package/dist/settings-control.js.map +1 -1
- package/dist/settings-effect.js +29 -4
- package/dist/settings-effect.js.map +1 -1
- package/dist/settings-http-confirmation.d.ts +13 -0
- package/dist/settings-http-confirmation.js +15 -3
- package/dist/settings-http-confirmation.js.map +1 -1
- package/dist/settings-instance-patch.js +13 -1
- package/dist/settings-instance-patch.js.map +1 -1
- package/dist/settings-request-capability.d.ts +4 -1
- package/dist/settings-request-capability.js +11 -3
- package/dist/settings-request-capability.js.map +1 -1
- package/dist/settings-transaction.js.map +1 -1
- package/dist/setup-auth.d.ts +1 -0
- package/dist/setup-auth.js +2 -1
- package/dist/setup-auth.js.map +1 -1
- package/dist/setup-form.d.ts +1 -1
- package/dist/setup-form.js +23 -7
- package/dist/setup-form.js.map +1 -1
- package/dist/setup-host.d.ts +6 -1
- package/dist/setup-host.js +15 -4
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +6 -3
- package/dist/setup-wizard.js.map +1 -1
- package/dist/spawn-gate.d.ts +7 -0
- package/dist/spawn-gate.js +45 -12
- package/dist/spawn-gate.js.map +1 -1
- package/dist/start-attempt.d.ts +11 -0
- package/dist/start-attempt.js +29 -0
- package/dist/start-attempt.js.map +1 -0
- package/dist/start-failure-chat.d.ts +37 -0
- package/dist/start-failure-chat.js +142 -0
- package/dist/start-failure-chat.js.map +1 -0
- package/dist/start-failure-notice.d.ts +4 -0
- package/dist/start-failure-notice.js +20 -0
- package/dist/start-failure-notice.js.map +1 -0
- package/dist/start-failure.d.ts +171 -0
- package/dist/start-failure.js +348 -0
- package/dist/start-failure.js.map +1 -0
- package/dist/startup-memory-deadline.d.ts +23 -0
- package/dist/startup-memory-deadline.js +55 -0
- package/dist/startup-memory-deadline.js.map +1 -0
- package/dist/statusline-watcher.js.map +1 -1
- package/dist/storm-window.js.map +1 -1
- package/dist/systemd-peer-bus.js.map +1 -1
- package/dist/target-queue-worker.js.map +1 -1
- package/dist/task-board-summary.d.ts +101 -0
- package/dist/task-board-summary.js +177 -0
- package/dist/task-board-summary.js.map +1 -0
- package/dist/task-board-tool.d.ts +25 -0
- package/dist/task-board-tool.js +155 -0
- package/dist/task-board-tool.js.map +1 -0
- package/dist/task-board-waker.d.ts +51 -0
- package/dist/task-board-waker.js +141 -0
- package/dist/task-board-waker.js.map +1 -0
- package/dist/telegram-api-root.d.ts +1 -0
- package/dist/telegram-api-root.js +40 -0
- package/dist/telegram-api-root.js.map +1 -0
- package/dist/tips.js.map +1 -1
- package/dist/tmux-control.js +1 -1
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-exec-helper.d.ts +104 -0
- package/dist/tmux-exec-helper.js +412 -0
- package/dist/tmux-exec-helper.js.map +1 -0
- package/dist/tmux-manager.d.ts +1 -0
- package/dist/tmux-manager.js +29 -13
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tmux-read.js +2 -2
- package/dist/tmux-read.js.map +1 -1
- package/dist/tool-permissions.js.map +1 -1
- package/dist/tool-progress.js.map +1 -1
- package/dist/topic-archiver.js.map +1 -1
- package/dist/topic-commands.d.ts +13 -0
- package/dist/topic-commands.js +56 -33
- package/dist/topic-commands.js.map +1 -1
- package/dist/topic-link.js.map +1 -1
- package/dist/transcript-delta-reader.js.map +1 -1
- package/dist/transcript-jsonl.js.map +1 -1
- package/dist/transcript-monitor.js +5 -0
- package/dist/transcript-monitor.js.map +1 -1
- package/dist/transcript-sources.d.ts +2 -0
- package/dist/transcript-sources.js +4 -0
- package/dist/transcript-sources.js.map +1 -1
- package/dist/tunnel/cloudflared-install.js +3 -1
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/tunnel/cloudflared.d.ts +7 -16
- package/dist/tunnel/cloudflared.js +100 -13
- package/dist/tunnel/cloudflared.js.map +1 -1
- package/dist/tunnel/lease.js +3 -2
- package/dist/tunnel/lease.js.map +1 -1
- package/dist/tunnel/manager.js.map +1 -1
- package/dist/tunnel/named-diagnostics.d.ts +13 -0
- package/dist/tunnel/named-diagnostics.js +52 -0
- package/dist/tunnel/named-diagnostics.js.map +1 -0
- package/dist/tunnel/public-fetch.js.map +1 -1
- package/dist/tunnel/purpose-lane.js.map +1 -1
- package/dist/tunnel/types.d.ts +4 -1
- package/dist/tunnel/types.js.map +1 -1
- package/dist/types.d.ts +56 -2
- package/dist/ui/app-effects.js +23 -5
- package/dist/ui/app.html +2 -0
- package/dist/ui/backend-signin.js +297 -0
- package/dist/ui/chat-effects.js +10 -3
- package/dist/ui/chat-msg-actions.js +176 -0
- package/dist/ui/chat-render.js +62 -18
- package/dist/ui/chat-store.js +218 -48
- package/dist/ui/chat-strings.js +40 -8
- package/dist/ui/chat-thread.js +119 -10
- package/dist/ui/effect-trial-offer.js +2 -31
- package/dist/ui/effects-waker.js +2 -0
- package/dist/ui/fleet-canvas-layout.js +13 -4
- package/dist/ui/fleet-canvas.js +69 -28
- package/dist/ui/fleet-office-editor.js +149 -0
- package/dist/ui/fleet-office-model.js +358 -0
- package/dist/ui/fleet-office-pack.js +199 -0
- package/dist/ui/fleet-office-skin.js +474 -0
- package/dist/ui/fleet-office-store.js +49 -0
- package/dist/ui/fleet-office-zones.js +177 -0
- package/dist/ui/fleet-office.js +434 -0
- package/dist/ui/fleet-org.js +18 -5
- package/dist/ui/fleet-route-scene.js +272 -0
- package/dist/ui/fleet-route-worker.js +21 -0
- package/dist/ui/fleet-route.js +88 -0
- package/dist/ui/fleet-task-card.js +436 -0
- package/dist/ui/fleet-task-common.js +482 -0
- package/dist/ui/fleet-task-work.js +107 -0
- package/dist/ui/fleet-tasks.js +569 -0
- package/dist/ui/fleet-traffic.js +81 -18
- package/dist/ui/fleet-usage.js +36 -2
- package/dist/ui/guest-refused.html +41 -0
- package/dist/ui/icons/icon-192.png +0 -0
- package/dist/ui/icons/icon-512.png +0 -0
- package/dist/ui/image-lightbox.js +45 -1
- package/dist/ui/image-zoom.js +242 -0
- package/dist/ui/panel-chat.js +214 -57
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +32 -71
- package/dist/ui/panel-log.js +3 -3
- package/dist/ui/panel-needs.js +23 -6
- package/dist/ui/panel-settings.js +166 -33
- package/dist/ui/preview-panel.js +2 -1
- package/dist/ui/search-dialog.js +350 -0
- package/dist/ui/settings-appearance.js +170 -10
- package/dist/ui/settings-apply.js +3 -3
- package/dist/ui/settings-confirm.js +7 -2
- package/dist/ui/settings-dialogs.js +46 -21
- package/dist/ui/settings-discord.js +6 -2
- package/dist/ui/settings-guests.js +2 -1
- package/dist/ui/settings-learn.js +1 -0
- package/dist/ui/settings-model.js +51 -18
- package/dist/ui/settings-mods.js +8 -3
- package/dist/ui/settings-owner-effects.js +6 -4
- package/dist/ui/settings-strings.js +186 -6
- package/dist/ui/settings-token.js +3 -1
- package/dist/ui/settings-web-authority.js +13 -5
- package/dist/ui/settings-wizard.js +13 -7
- package/dist/ui/shared/agend-auth.js +2 -0
- package/dist/ui/shared/app-i18n.js +12 -5
- package/dist/ui/shared/app-needs.js +2 -2
- package/dist/ui/shared/app-prefs.js +15 -0
- package/dist/ui/shared/app-route.js +18 -0
- package/dist/ui/shared/app-shell.js +189 -28
- package/dist/ui/shared/app-store.js +3 -2
- package/dist/ui/shared/app-stream.js +5 -1
- package/dist/ui/shared/app-themes.js +116 -1
- package/dist/ui/shared/app.css +668 -48
- package/dist/ui/shared/app.js +9 -1
- package/dist/ui/shared/card-strings.js +4 -0
- package/dist/ui/shared/effect-app-runtime.js +7 -5
- package/dist/ui/shared/effect-chat-runtime.js +2 -7
- package/dist/ui/shared/effect-cursor.js +3 -2
- package/dist/ui/shared/effect-display-metadata.js +39 -0
- package/dist/ui/shared/effect-owner-source.js +8 -1
- package/dist/ui/shared/effect-registry.js +14 -4
- package/dist/ui/shared/effect-runtime-loader.js +3 -1
- package/dist/ui/shared/effect-stock-trust.js +5 -5
- package/dist/ui/shared/feature-flags.js +127 -0
- package/dist/ui/shared/guest-refused.js +35 -0
- package/dist/ui/shared/header-tools.js +2 -2
- package/dist/ui/shared/identity-card.js +26 -8
- package/dist/ui/shared/identity-color.js +17 -10
- package/dist/ui/shared/instance-nav.js +183 -28
- package/dist/ui/shared/owner-css.js +30 -1
- package/dist/ui/shared/panel-view.js +14 -29
- package/dist/ui/shared/text-undo.js +210 -0
- package/dist/ui/shared/tokens.css +4 -0
- package/dist/ui/shared/ui-dialog.js +5 -4
- package/dist/ui/shared/ui-icons.js +4 -0
- package/dist/ui/shared/ui-menu.js +20 -7
- package/dist/ui/shared/ui-toast.js +16 -3
- package/dist/ui/shared/usage-dialog.js +216 -16
- package/dist/ui/shared/view-strings.js +70 -4
- package/dist/ui/shared/web-authorize.js +50 -1
- package/dist/ui/signin.html +2 -0
- package/dist/ui/web-terminal/terminal.html +10 -10
- package/dist/ui/web-terminal/terminal.js +58 -12
- package/dist/update-check.js.map +1 -1
- package/dist/update-dispatch.js +1 -1
- package/dist/update-dispatch.js.map +1 -1
- package/dist/update-install.js.map +1 -1
- package/dist/update-launch.d.ts +10 -1
- package/dist/update-launch.js +39 -14
- package/dist/update-launch.js.map +1 -1
- package/dist/update-marker.js.map +1 -1
- package/dist/update-preflight.js +1 -1
- package/dist/update-preflight.js.map +1 -1
- package/dist/update-progress.d.ts +3 -1
- package/dist/update-progress.js +3 -1
- package/dist/update-progress.js.map +1 -1
- package/dist/usage/format-rich.js +6 -3
- package/dist/usage/format-rich.js.map +1 -1
- package/dist/usage/i18n-keys.d.ts +1 -1
- package/dist/usage/i18n-keys.js +2 -1
- package/dist/usage/i18n-keys.js.map +1 -1
- package/dist/usage/i18n.d.ts +13 -1
- package/dist/usage/i18n.js +29 -0
- package/dist/usage/i18n.js.map +1 -1
- package/dist/usage/providers.d.ts +89 -2
- package/dist/usage/providers.js +247 -41
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/statusline-usage.js.map +1 -1
- package/dist/usage/usage-api.d.ts +5 -1
- package/dist/usage/usage-api.js +185 -58
- package/dist/usage/usage-api.js.map +1 -1
- package/dist/usage-report.js.map +1 -1
- package/dist/usage-rollup.js +1 -1
- package/dist/usage-rollup.js.map +1 -1
- package/dist/user-systemd.d.ts +24 -0
- package/dist/user-systemd.js +75 -0
- package/dist/user-systemd.js.map +1 -0
- package/dist/view-api.d.ts +7 -0
- package/dist/view-api.js +16 -4
- package/dist/view-api.js.map +1 -1
- package/dist/wake-coordinator.js.map +1 -1
- package/dist/web-ambient.js.map +1 -1
- package/dist/web-api.d.ts +58 -11
- package/dist/web-api.js +1104 -71
- package/dist/web-api.js.map +1 -1
- package/dist/web-app-icon-worker.d.ts +1 -0
- package/dist/web-app-icon-worker.js +16 -0
- package/dist/web-app-icon-worker.js.map +1 -0
- package/dist/web-app-icon.d.ts +109 -0
- package/dist/web-app-icon.js +257 -0
- package/dist/web-app-icon.js.map +1 -0
- package/dist/web-appearance-policy.js.map +1 -1
- package/dist/web-appearance-tools.d.ts +1 -0
- package/dist/web-appearance-tools.js +7 -3
- package/dist/web-appearance-tools.js.map +1 -1
- package/dist/web-appearance-view.js.map +1 -1
- package/dist/web-appearance.d.ts +4 -10
- package/dist/web-appearance.js +7 -4
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-auth.d.ts +8 -4
- package/dist/web-auth.js +33 -21
- package/dist/web-auth.js.map +1 -1
- package/dist/web-backend-login.d.ts +130 -0
- package/dist/web-backend-login.js +225 -0
- package/dist/web-backend-login.js.map +1 -0
- package/dist/web-card.js.map +1 -1
- package/dist/web-channel-echo.js.map +1 -1
- package/dist/web-chat-history.d.ts +118 -8
- package/dist/web-chat-history.js +220 -25
- package/dist/web-chat-history.js.map +1 -1
- package/dist/web-commands.d.ts +5 -1
- package/dist/web-commands.js +1 -1
- package/dist/web-commands.js.map +1 -1
- package/dist/web-cookie-names.d.ts +5 -0
- package/dist/web-cookie-names.js +12 -0
- package/dist/web-cookie-names.js.map +1 -0
- package/dist/web-effect-bundle.d.ts +2 -2
- package/dist/web-effect-library.js.map +1 -1
- package/dist/web-effect-playback.js.map +1 -1
- package/dist/web-effect-profiles.d.ts +16 -16
- package/dist/web-effect-profiles.js +3 -2
- package/dist/web-effect-profiles.js.map +1 -1
- package/dist/web-effect-service.js.map +1 -1
- package/dist/web-effect-tools.js.map +1 -1
- package/dist/web-effect-trials.js.map +1 -1
- package/dist/web-effect-upload.js.map +1 -1
- package/dist/web-effect.d.ts +6 -6
- package/dist/web-effect.js.map +1 -1
- package/dist/web-experimental-http.d.ts +2 -0
- package/dist/web-experimental-http.js +12 -0
- package/dist/web-experimental-http.js.map +1 -0
- package/dist/web-experimental.d.ts +11 -0
- package/dist/web-experimental.js +49 -0
- package/dist/web-experimental.js.map +1 -0
- package/dist/web-guest-access.d.ts +4 -2
- package/dist/web-guest-access.js +22 -10
- package/dist/web-guest-access.js.map +1 -1
- package/dist/web-guest-rooms.js.map +1 -1
- package/dist/web-host-guard.d.ts +11 -0
- package/dist/web-host-guard.js +11 -0
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-icons.d.ts +33 -1
- package/dist/web-icons.js +83 -11
- package/dist/web-icons.js.map +1 -1
- package/dist/web-identity-color.js.map +1 -1
- package/dist/web-image-meta.d.ts +13 -0
- package/dist/web-image-meta.js +62 -0
- package/dist/web-image-meta.js.map +1 -1
- package/dist/web-images.js.map +1 -1
- package/dist/web-libavoid.d.ts +14 -0
- package/dist/web-libavoid.js +46 -0
- package/dist/web-libavoid.js.map +1 -0
- package/dist/web-login.js.map +1 -1
- package/dist/web-mentions.js.map +1 -1
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-mod-store.d.ts +2 -0
- package/dist/web-mod-store.js +10 -0
- package/dist/web-mod-store.js.map +1 -1
- package/dist/web-office-store.d.ts +104 -0
- package/dist/web-office-store.js +236 -0
- package/dist/web-office-store.js.map +1 -0
- package/dist/web-owner-css-store.d.ts +44 -1
- package/dist/web-owner-css-store.js +70 -9
- package/dist/web-owner-css-store.js.map +1 -1
- package/dist/web-owner-css.js.map +1 -1
- package/dist/web-png.d.ts +33 -0
- package/dist/web-png.js +329 -0
- package/dist/web-png.js.map +1 -0
- package/dist/web-prefs.d.ts +6 -0
- package/dist/web-prefs.js +29 -3
- package/dist/web-prefs.js.map +1 -1
- package/dist/web-preview.d.ts +2 -0
- package/dist/web-preview.js +2 -0
- package/dist/web-preview.js.map +1 -1
- package/dist/web-profile.js.map +1 -1
- package/dist/web-reactions.d.ts +35 -0
- package/dist/web-reactions.js +47 -0
- package/dist/web-reactions.js.map +1 -0
- package/dist/web-request-context.d.ts +5 -0
- package/dist/web-request-context.js.map +1 -1
- package/dist/web-returning-device.d.ts +4 -2
- package/dist/web-returning-device.js +13 -6
- package/dist/web-returning-device.js.map +1 -1
- package/dist/web-schedules.js.map +1 -1
- package/dist/web-send-dedupe.d.ts +36 -0
- package/dist/web-send-dedupe.js +69 -0
- package/dist/web-send-dedupe.js.map +1 -0
- package/dist/web-session-authorization.js.map +1 -1
- package/dist/web-session.js +1 -1
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +3 -1
- package/dist/web-settings-patch.js +11 -5
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-settings-tools.d.ts +6 -0
- package/dist/web-settings-tools.js +52 -17
- package/dist/web-settings-tools.js.map +1 -1
- package/dist/web-terminal-http.js +15 -2
- package/dist/web-terminal-http.js.map +1 -1
- package/dist/web-terminal.js +1 -1
- package/dist/web-terminal.js.map +1 -1
- package/dist/web-theme-store.d.ts +2 -2
- package/dist/web-theme-store.js +6 -3
- package/dist/web-theme-store.js.map +1 -1
- package/dist/web-theme.d.ts +29 -0
- package/dist/web-theme.js +89 -1
- package/dist/web-theme.js.map +1 -1
- package/dist/web-upload.d.ts +18 -1
- package/dist/web-upload.js +45 -7
- package/dist/web-upload.js.map +1 -1
- package/dist/web-widget.js.map +1 -1
- package/dist/webhook-emitter.js.map +1 -1
- package/dist/windows-shortcuts.d.ts +78 -0
- package/dist/windows-shortcuts.js +203 -0
- package/dist/windows-shortcuts.js.map +1 -0
- package/dist/workflow-templates/default.md +3 -0
- package/dist/working-state-journal.d.ts +68 -0
- package/dist/working-state-journal.js +93 -0
- package/dist/working-state-journal.js.map +1 -0
- package/dist/ws-server.js.map +1 -1
- package/dist/yaml-shared-nodes.d.ts +25 -0
- package/dist/yaml-shared-nodes.js +185 -0
- package/dist/yaml-shared-nodes.js.map +1 -0
- package/launcher/agend-install.cjs +828 -0
- package/launcher/install-admission.cjs +4 -39
- package/launcher/postinstall.cjs +14 -12
- package/launcher/private-home.cjs +43 -0
- package/package.json +16 -13
- package/scripts/preinstall-guard.cjs +2 -5
- package/templates/windows-shortcuts.ps1 +181 -0
- package/launcher/old-updater-note.cjs +0 -48
package/dist/fleet-manager.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { PUBLIC_TUNNEL_TOKEN_ENV, PUBLIC_TUNNEL_TOKEN_HINT, normalizePublicTunnelToken } from "./public-tunnel-config.js";
|
|
1
2
|
import { humanOwnerObjectId, webHumanOrigin } from "./human-delivery-receipts.js";
|
|
2
3
|
import { SettingsBaselines } from "./settings-baseline.js";
|
|
3
|
-
import { SettingsConfirmationStore } from "./settings-confirmation.js";
|
|
4
|
+
import { SettingsConfirmationStore, settingsSourceLabel } from "./settings-confirmation.js";
|
|
4
5
|
import { SettingsHttpConfirmation } from "./settings-http-confirmation.js";
|
|
5
6
|
import { SettingsControlServer } from "./settings-control.js";
|
|
6
7
|
import { SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileResource, assertSettingsLease, trySettingsLease, waitSettingsLease, settingsUndo, undoSettingsPaths } from "./settings-transaction.js";
|
|
@@ -8,10 +9,13 @@ import { performance } from "node:perf_hooks";
|
|
|
8
9
|
import { withSpawnHoldReply } from "./spawn-hold-reply.js";
|
|
9
10
|
import { gatewayRequestContext } from "./web-request-context.js";
|
|
10
11
|
import { createPublicWebGateway, PUBLIC_GATEWAY_RECEIVE_MS } from "./public-web-gateway.js";
|
|
11
|
-
import { handleWebIconRequest } from "./web-icons.js";
|
|
12
|
+
import { handleWebIconRequest, useAppIconStore } from "./web-icons.js";
|
|
13
|
+
import { AppIconStore } from "./web-app-icon.js";
|
|
12
14
|
import { trackWebServer } from "./web-server-drain.js";
|
|
13
15
|
import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
|
|
14
|
-
import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
|
|
16
|
+
import { PublicWebLink, publicLinkNamedHostname, publicLinkSettings } from "./public-web-link.js";
|
|
17
|
+
import { PublicLinkAutoOpen } from "./public-link-auto-open.js";
|
|
18
|
+
import { clearPublicLinkOwner, readPublicLinkOwner, writePublicLinkOwner } from "./public-link-owner.js";
|
|
15
19
|
import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
|
|
16
20
|
import { CloudflaredProvider } from "./tunnel/cloudflared.js";
|
|
17
21
|
import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
|
|
@@ -20,24 +24,27 @@ import { withinBudget } from "./monotonic-budget.js";
|
|
|
20
24
|
import { measureSyncWork } from "./sync-work-attribution.js";
|
|
21
25
|
import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
|
|
22
26
|
import { ProfileControlServer } from "./profile-control.js";
|
|
23
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants, closeSync } from "node:fs";
|
|
27
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, lstatSync, constants as fsConstants, closeSync } from "node:fs";
|
|
24
28
|
import { atomicWriteFileSync } from "./atomic-write.js";
|
|
25
29
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
26
30
|
import { freemem, totalmem, cpus, homedir } from "node:os";
|
|
27
31
|
import { createServer } from "node:http";
|
|
28
32
|
import { Worker } from "node:worker_threads";
|
|
29
33
|
import { ProbeWorkerPool } from "./probe-worker-pool.js";
|
|
30
|
-
import { join, dirname, basename, delimiter, isAbsolute, resolve as resolvePath } from "node:path";
|
|
34
|
+
import { join, dirname, basename, delimiter, isAbsolute, relative, resolve as resolvePath } from "node:path";
|
|
31
35
|
import { fileURLToPath } from "node:url";
|
|
32
36
|
import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
|
|
33
37
|
import { fleetLabel } from "./fleet-label.js";
|
|
34
38
|
import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
|
|
35
39
|
import { formatUpdateProgress } from "./update-progress.js";
|
|
36
40
|
import { sdNotify, sdNotifyBlocking, setSdNotifyWarn } from "./sd-notify.js";
|
|
41
|
+
import { StartupMemoryDeadline, fleetStopConcurrency } from "./startup-memory-deadline.js";
|
|
37
42
|
import { readFleetMemory } from "./process-memory.js";
|
|
38
43
|
import { MemoryPressure } from "./memory-pressure.js";
|
|
39
44
|
import { replyDedupText, ReplyDeduper } from "./reply-dedup.js";
|
|
40
45
|
import { isMap, isScalar, parseDocument } from "yaml";
|
|
46
|
+
import { changedOutside, detachForWrite } from "./yaml-shared-nodes.js";
|
|
47
|
+
import { tmuxExecHelper } from "./tmux-exec-helper.js";
|
|
41
48
|
import { kiroLogGate } from "./kiro-log-gate.js";
|
|
42
49
|
const __filename = fileURLToPath(import.meta.url);
|
|
43
50
|
const __dirname = dirname(__filename);
|
|
@@ -71,10 +78,14 @@ import { createLogger, rotateLogIfNeeded } from "./logger.js";
|
|
|
71
78
|
import { processAttachments } from "./channel/attachment-handler.js";
|
|
72
79
|
import { routeToolCall } from "./channel/tool-router.js";
|
|
73
80
|
import { Scheduler } from "./scheduler/index.js";
|
|
81
|
+
import { boardActivity, boardCardLimit, boardToolAction } from "./task-board-tool.js";
|
|
82
|
+
import { TaskBoardWaker } from "./task-board-waker.js";
|
|
83
|
+
import { TaskBoardError, waitsForPerson, wrapUpGap } from "./scheduler/task-board.js";
|
|
74
84
|
import { escapeTelegramHtml, scheduleClock, scheduleRetryLabel } from "./scheduler/retry-label.js";
|
|
75
85
|
import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
|
|
76
|
-
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, parseCompactCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext,
|
|
86
|
+
import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, parseCompactCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext, readStatuslineJson, statuslineModel } from "./topic-commands.js";
|
|
77
87
|
import { DailySummary } from "./daily-summary.js";
|
|
88
|
+
import { TaskBoardSummaryClock, buildSummary, isQuiet, renderSummary, summaryMarkFile, summaryTime } from "./task-board-summary.js";
|
|
78
89
|
import { WebhookEmitter } from "./webhook-emitter.js";
|
|
79
90
|
import { TmuxControlClient } from "./tmux-control.js";
|
|
80
91
|
import { safeHandler } from "./safe-async.js";
|
|
@@ -101,7 +112,8 @@ export const TASK_LIST_CAP = 100;
|
|
|
101
112
|
* #1336: the non-terminal statuses a bare `list` returns by default. `done`
|
|
102
113
|
* and `cancelled` are excluded unless the caller passes `filter_status`.
|
|
103
114
|
*/
|
|
104
|
-
|
|
115
|
+
/** Not finished: `review` is done work waiting for the person (the task board), still live. */
|
|
116
|
+
export const LIVE_TASK_STATUSES = ["open", "claimed", "blocked", "review"];
|
|
105
117
|
/**
|
|
106
118
|
* #1336 P2: shared normalization for filter_status across both task handlers
|
|
107
119
|
* and the cap helper. Trims whitespace before dropping empties, so
|
|
@@ -135,15 +147,17 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
|
|
|
135
147
|
}
|
|
136
148
|
return tasks;
|
|
137
149
|
}
|
|
138
|
-
import { handleWebRequest, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
150
|
+
import { handleWebRequest, webPeerOf, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
|
|
139
151
|
import { LogService } from "./log-service.js";
|
|
140
152
|
import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId, webChatFrom } from "./web-chat-history.js";
|
|
153
|
+
import { WEB_ECHO_COPIES_MAX, WEB_MIRROR_TIMEOUT_MS, webReactionEmoji, webReactionProblem } from "./web-reactions.js";
|
|
141
154
|
import { WebChatDiskStore } from "./web-chat-store.js";
|
|
142
155
|
import { SeenUsers, resolveMentions } from "./web-mentions.js";
|
|
143
156
|
import { OPERATOR, WebPrefsStore } from "./web-prefs.js";
|
|
144
157
|
import { agendVersion } from "./agent-whats-new.js";
|
|
145
158
|
import { OwnerCssStore } from "./web-owner-css-store.js";
|
|
146
159
|
import { ThemeStore } from "./web-theme-store.js";
|
|
160
|
+
import { OfficeStore, OfficeWriteLimiter } from "./web-office-store.js";
|
|
147
161
|
import { WebAppearance } from "./web-appearance.js";
|
|
148
162
|
import { WebEffectLibrary } from "./web-effect-library.js";
|
|
149
163
|
import { WebEffectTrials } from "./web-effect-trials.js";
|
|
@@ -159,7 +173,7 @@ import { ReplyButtonsController } from "./reply-buttons-controller.js";
|
|
|
159
173
|
import { parsePoll } from "./reply-polls.js";
|
|
160
174
|
import { isUploadRequest, limitRequestReceive, publicAttachment, sweepOrphanedUploads, UPLOAD_REQUEST_TIMEOUT_MS, uploadLimits, WebFileLedger } from "./web-upload.js";
|
|
161
175
|
import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
|
|
162
|
-
import { cardPicture, handleViewRequest, humanCards, identityColors, isViewPath, personBannerFocus, personIdentityColour, profileIdentities, resolveInstanceIdentity, setPersonBannerFocus, setPersonIdentityColour } from "./view-api.js";
|
|
176
|
+
import { cardPicture, handleViewRequest, humanCards, identityColors, instanceTeams, isViewPath, personBannerFocus, personIdentityColour, profileIdentities, resolveInstanceIdentity, setPersonBannerFocus, setPersonIdentityColour } from "./view-api.js";
|
|
163
177
|
import { autoIdentityColor } from "./web-identity-color.js";
|
|
164
178
|
import { AppearanceTools } from "./web-appearance-tools.js";
|
|
165
179
|
import { cardVersion, resolveCard } from "./web-card.js";
|
|
@@ -179,7 +193,9 @@ import { runOnLoginShell } from "./login-shell.js";
|
|
|
179
193
|
import { tightenInstanceDirs } from "./private-dir.js";
|
|
180
194
|
import { decideSlash } from "./slash-authz.js";
|
|
181
195
|
import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command-table.js";
|
|
182
|
-
import { crossInstanceVisibility, runVisibilityCommand } from "./cross-instance-notice.js";
|
|
196
|
+
import { crossInstanceVisibility, runVisibilityCommand, xmsgVisibility } from "./cross-instance-notice.js";
|
|
197
|
+
import { SEARCH_RETENTION_DAYS, SearchIndex } from "./search/search-index.js";
|
|
198
|
+
import { chatDoc, taskDoc, xmsgDoc } from "./search/search-docs.js";
|
|
183
199
|
import { WebSettingsTools } from "./web-settings-tools.js";
|
|
184
200
|
import { bindGuestRequest, guestRouteDecision } from "./web-guest-access.js";
|
|
185
201
|
import { GUEST_ROOM_TICK_MS, GuestRoomMembership, guestGroupRooms } from "./web-guest-rooms.js";
|
|
@@ -188,6 +204,7 @@ import { installedChannel, updateNoticeKey } from "./update-check.js";
|
|
|
188
204
|
import { resolveInstalledAgend } from "./update-dispatch.js";
|
|
189
205
|
import { resolveUpdateLaunch, openUpdateLog, updateLogPath, watchUpdateLaunch } from "./update-launch.js";
|
|
190
206
|
import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
207
|
+
import { WebBackendLogins, dashboardSignInSupported } from "./web-backend-login.js";
|
|
191
208
|
import { runBeforeDeadline } from "./deadline.js";
|
|
192
209
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
193
210
|
import { handleSettingsRequest } from "./settings-api.js";
|
|
@@ -200,6 +217,9 @@ import { assertExplicitInstanceRemoval } from "./instance-removal.js";
|
|
|
200
217
|
import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
|
|
201
218
|
import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
|
|
202
219
|
import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
|
|
220
|
+
import { describeSelfOom } from "./session-oom.js";
|
|
221
|
+
import { assertNoSessionOomStop, readSessionOomStop, captureSessionOomRetry, clearSessionOomStopForRetry, SessionOomStoppedError, sessionOomStopHint } from "./session-oom-stop.js";
|
|
222
|
+
import { idleGateTimeoutLog } from "./idle-gate-log.js";
|
|
203
223
|
import { DEFAULT_STUCK_TIMEOUT_MS, readLastInboundAt } from "./daemon.js";
|
|
204
224
|
import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
|
|
205
225
|
import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
|
|
@@ -222,9 +242,10 @@ import { CompactWatch, launchIdentity } from "./compact-watch.js";
|
|
|
222
242
|
import { agyLegacyAgentsFile, danglingSymlink, migrateAgyAgentsFile } from "./backend/antigravity.js";
|
|
223
243
|
import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
|
|
224
244
|
import { sharedRolloutIndex } from "./rollout-index.js";
|
|
245
|
+
import { TASK_BOARD_ON_NOTICE, dashboardRefusalIn, taskBoardSwitchedOn } from "./dashboard-permissions.js";
|
|
225
246
|
import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
|
|
226
|
-
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
|
|
227
|
-
import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
|
|
247
|
+
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken, sessionCookieUpgrade } from "./web-auth.js";
|
|
248
|
+
import { bypassesWebGate, handleAuthRequest, serveGuestRefusedPage, serveSigninPage } from "./auth-api.js";
|
|
228
249
|
import { isWebPageNavigation } from "./web-shell-routes.js";
|
|
229
250
|
import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
230
251
|
import { authorityNow, evaluateWebAuthority, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
|
|
@@ -236,22 +257,27 @@ import { decisionListAnswer } from "./decision-list.js";
|
|
|
236
257
|
import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
|
|
237
258
|
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, LOOPBACK_HOST_NAMES, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
238
259
|
import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
|
|
260
|
+
import { canvasWorkerEnabled, isCanvasWorkerTool, CANVAS_WORKER_EXPERIMENTAL_MESSAGE } from "./web-experimental.js";
|
|
261
|
+
import { isCanvasWorkerHttpPath } from "./web-experimental-http.js";
|
|
262
|
+
import { bundledSkillContents } from "./bundled-skills.js";
|
|
239
263
|
import { createEffectBootstrap } from "./web-effect-bootstrap.js";
|
|
240
264
|
import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
|
|
241
265
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
242
266
|
import { SecretStore } from "./secret-store.js";
|
|
243
267
|
import { opaqueId, safeSecretError, SECRET_CHALLENGE_TTL_MS, } from "./connection-secrets.js";
|
|
244
268
|
import { verifyDiscordToken, verifyTelegramToken } from "./provider-probe.js";
|
|
245
|
-
import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isReservedProviderEnvKey, verifyProviderSecret, } from "./provider-secret-registry.js";
|
|
269
|
+
import { PROVIDER_SECRET_SPECS, PUBLIC_TUNNEL_SECRET_SPEC, providerSecretSpec, providerRegistryEnvKeys, isReservedProviderEnvKey, verifyProviderSecret, } from "./provider-secret-registry.js";
|
|
246
270
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
247
271
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
248
272
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
249
|
-
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
273
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileLoginCommand, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
250
274
|
import { carryClaudeConversation } from "./backend/claude-carry.js";
|
|
251
275
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
252
276
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
253
|
-
import { cleanupWatchVerdict, formatRestartProgressCompletion,
|
|
277
|
+
import { cleanupWatchVerdict, formatRestartProgressCompletion, RestartProgress, } from "./restart-progress.js";
|
|
278
|
+
import { FleetStartup } from "./fleet-startup.js";
|
|
254
279
|
import { launchFullRestartHelper } from "./full-restart.js";
|
|
280
|
+
import { fullReloadExitCode } from "./service-owner.js";
|
|
255
281
|
import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
|
|
256
282
|
import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
|
|
257
283
|
import { StormWindow } from "./storm-window.js";
|
|
@@ -259,6 +285,14 @@ import { SpawnGate } from "./spawn-gate.js";
|
|
|
259
285
|
import { BackendOutageTracker } from "./backend-outage.js";
|
|
260
286
|
import { canUnlockAdvancedTips, DailyTipScheduler, selectTip, visibleTipLevels, } from "./tips.js";
|
|
261
287
|
import { getTmuxSession } from "./config.js";
|
|
288
|
+
// Closed migration cohort: these 11 names existed immediately before the first
|
|
289
|
+
// managed-skill publisher (a1ac499f; parent e267e684). Never grow this set when
|
|
290
|
+
// adding a bundled skill: its new name is not proof of old AgEnD ownership.
|
|
291
|
+
const PRE_MANIFEST_KIRO_SKILLS = new Set([
|
|
292
|
+
"backend-providers", "cross-instance-messaging", "fleet-config", "fleet-health",
|
|
293
|
+
"fleet-restart", "instance-lifecycle", "model-discovery", "multi-channel",
|
|
294
|
+
"scheduling", "session-management", "tui-effort",
|
|
295
|
+
]);
|
|
262
296
|
export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
263
297
|
if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
|
|
264
298
|
return argsThreadId;
|
|
@@ -295,6 +329,41 @@ export function selectLruEvictions(warm, cap, opts) {
|
|
|
295
329
|
* fleet.yaml. Keep in sync with `uniqueInstanceName` — every name it emits
|
|
296
330
|
* must match here, or a deleted instance's CLI is left running (#1305 P2-3).
|
|
297
331
|
*/
|
|
332
|
+
/**
|
|
333
|
+
* #2163: where `p` really is — symlinks resolved. A path that does not exist yet is resolved through its deepest
|
|
334
|
+
* existing ancestor (a missing child of a link elsewhere is elsewhere too).
|
|
335
|
+
*/
|
|
336
|
+
function realPathThroughAncestors(p) {
|
|
337
|
+
const missing = [];
|
|
338
|
+
let at = resolvePath(p);
|
|
339
|
+
for (;;) {
|
|
340
|
+
try {
|
|
341
|
+
return join(realpathSync(at), ...missing.reverse());
|
|
342
|
+
}
|
|
343
|
+
catch { /* not there yet: try its parent */ }
|
|
344
|
+
try {
|
|
345
|
+
lstatSync(at);
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
catch { /* truly absent — not a broken link whose target is unknown */ }
|
|
349
|
+
const up = dirname(at);
|
|
350
|
+
if (up === at)
|
|
351
|
+
return resolvePath(p);
|
|
352
|
+
missing.push(basename(at));
|
|
353
|
+
at = up;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* #2163: whether `dir` is inside AgEnD's own workspaces (<AGEND_HOME>/workspaces/<name>/…), compared after resolving
|
|
358
|
+
* symlinks on both sides — including a missing folder's existing ancestors — so a link elsewhere is not inside, nor is a broken link.
|
|
359
|
+
*/
|
|
360
|
+
export function insideManagedWorkspaces(dir, agendHome = getAgendHome()) {
|
|
361
|
+
const root = realPathThroughAncestors(join(agendHome, "workspaces")), real = realPathThroughAncestors(dir);
|
|
362
|
+
if (root === null || real === null)
|
|
363
|
+
return false;
|
|
364
|
+
const rel = relative(root, real);
|
|
365
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
366
|
+
}
|
|
298
367
|
export function isOrphanInstanceWindowName(name) {
|
|
299
368
|
return /-t\d+$/.test(name) || /^classic-/.test(name);
|
|
300
369
|
}
|
|
@@ -393,6 +462,7 @@ const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
|
|
|
393
462
|
const BACKEND_SELECT_CALLBACK_PREFIX = "backend-select:";
|
|
394
463
|
const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
|
|
395
464
|
const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
|
|
465
|
+
const OOM_RETRY_CALLBACK_PREFIX = "oom-retry:";
|
|
396
466
|
/** #1386: how long a paused instance's marker reason is reused before it is read again (transitions drop it at once). */
|
|
397
467
|
const NEEDS_PAUSE_TTL_MS = 10_000;
|
|
398
468
|
const HANG_CALLBACK_PREFIX = "hang:";
|
|
@@ -404,7 +474,7 @@ const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
|
|
|
404
474
|
* group's approval, tips, and the per-user /model and /effort menus (which are not nonce entries at all).
|
|
405
475
|
*/
|
|
406
476
|
const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
|
|
407
|
-
HANG_CALLBACK_PREFIX, EXIT_RESTART_CALLBACK_PREFIX, INTERACTIVE_ASSIST_CALLBACK_PREFIX,
|
|
477
|
+
HANG_CALLBACK_PREFIX, EXIT_RESTART_CALLBACK_PREFIX, OOM_RETRY_CALLBACK_PREFIX, INTERACTIVE_ASSIST_CALLBACK_PREFIX,
|
|
408
478
|
]);
|
|
409
479
|
/**
|
|
410
480
|
* Where a reply goes on a fleet with no chat platform (web track C4): it is "sent" by being shown in the
|
|
@@ -554,6 +624,8 @@ export class FleetManager {
|
|
|
554
624
|
fleetConfig = null;
|
|
555
625
|
rawFleetConfig = {};
|
|
556
626
|
rawFleetDocument = null;
|
|
627
|
+
/** #2208: the paths a saveFleetConfig() wrote, for its check that nothing else changed. */
|
|
628
|
+
fleetDocumentWrites = null;
|
|
557
629
|
savedFleetConfigSnapshot = null;
|
|
558
630
|
adapter = null;
|
|
559
631
|
worlds = new Map();
|
|
@@ -609,7 +681,8 @@ export class FleetManager {
|
|
|
609
681
|
]);
|
|
610
682
|
logger = createLogger("info");
|
|
611
683
|
/** Report each bundled skill collision once per fleet, across instance starts. */
|
|
612
|
-
|
|
684
|
+
/** #2482: per skills directory, the skipped set (names + their content) last said — this process's view of the record. */
|
|
685
|
+
skippedSkillsSaid = new Map();
|
|
613
686
|
topicCommands;
|
|
614
687
|
// sessionName → instanceName mapping for external sessions
|
|
615
688
|
sessionRegistry = new Map();
|
|
@@ -617,7 +690,11 @@ export class FleetManager {
|
|
|
617
690
|
costGuard = null;
|
|
618
691
|
statuslineWatcher;
|
|
619
692
|
dailySummary = null;
|
|
693
|
+
/** #2246 step 3: the task board's daily summary for the person (task-board-summary.ts). */
|
|
694
|
+
taskBoardSummary = null;
|
|
620
695
|
dailyTipScheduler = null;
|
|
696
|
+
/** The task board's wakes (step 2): an instance hears when someone else changes a card it owns or handed out. */
|
|
697
|
+
taskBoardWaker = null;
|
|
621
698
|
webhookEmitter = null;
|
|
622
699
|
// Topic icon + auto-archive state
|
|
623
700
|
topicIcons = {};
|
|
@@ -685,6 +762,8 @@ export class FleetManager {
|
|
|
685
762
|
* backend outage during a post-update herd). instanceName → pending retry.
|
|
686
763
|
*/
|
|
687
764
|
startupRetries = new Map();
|
|
765
|
+
/** #2484: instances whose automatic startup retry is running now (between its timer and its outcome). */
|
|
766
|
+
startupRetriesRunning = new Set();
|
|
688
767
|
/** Bumped by every explicit stop (incl. the stop half of a restart); fences the outage hand-off. */
|
|
689
768
|
explicitStopGeneration = new Map();
|
|
690
769
|
/** instanceName → outage hand-off currently executing; a repeat joins it. */
|
|
@@ -746,7 +825,11 @@ export class FleetManager {
|
|
|
746
825
|
// Adapter restart: prevents re-entrant restart attempts
|
|
747
826
|
adapterRestarting = new Set();
|
|
748
827
|
// Adapter isolation: track state per adapter for retry + visibility
|
|
828
|
+
/** #2175: this boot's progress message, whose completion may still be owed by its background retry. */
|
|
829
|
+
startupProgress = null;
|
|
830
|
+
startupDashboard = null;
|
|
749
831
|
adapterState = new Map();
|
|
832
|
+
adapterRetryInFlight = new Map();
|
|
750
833
|
/** Web Settings secret rotation is deliberately separate from reconnect
|
|
751
834
|
* recovery: a rotation must build a fresh provider client with the new token,
|
|
752
835
|
* and stale callbacks from the old client must not win. */
|
|
@@ -788,6 +871,7 @@ export class FleetManager {
|
|
|
788
871
|
effectBootstrap = createEffectBootstrap();
|
|
789
872
|
/** Fixed trusted document coherence, independent of the HTML preview listener; never a resource grant. */
|
|
790
873
|
get effectBoot() { return this.effectBootstrap.boot; }
|
|
874
|
+
canvasWorkerEnabled() { return canvasWorkerEnabled(this.fleetConfig?.web); }
|
|
791
875
|
healthServerDrain = null;
|
|
792
876
|
/** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
|
|
793
877
|
previewListener = null;
|
|
@@ -825,6 +909,7 @@ export class FleetManager {
|
|
|
825
909
|
/** Days of event/activity history to keep. */
|
|
826
910
|
static EVENT_LOG_RETENTION_DAYS = 30;
|
|
827
911
|
watchdogTimer = null;
|
|
912
|
+
startupMemoryDeadline = null;
|
|
828
913
|
eventLoopWatch = null;
|
|
829
914
|
startedAt = 0;
|
|
830
915
|
stormOpenNotifyTimer = null;
|
|
@@ -853,11 +938,35 @@ export class FleetManager {
|
|
|
853
938
|
webChatHistory = new WebChatHistory();
|
|
854
939
|
/** #1565: the web chat history's files (set at fleet start, once the home and the instances are known). */
|
|
855
940
|
webChatStore = null;
|
|
941
|
+
/** #2293: the dashboard's global search (null: web.search.enabled is false, or the fleet has not started). */
|
|
942
|
+
searchIndex = null;
|
|
856
943
|
/** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
|
|
857
944
|
// #1937: the upload limits are the config's, read on each use (agend reload applies a change to the next upload).
|
|
858
945
|
webFiles = new WebFileLedger({ limits: () => uploadLimits(this.fleetConfig?.web) });
|
|
859
946
|
/** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
|
|
860
947
|
webReplyOrder = new Map();
|
|
948
|
+
/**
|
|
949
|
+
* #1774: an instance name's reply generation — moved on when a removal of it starts, so replies sent from then on (a
|
|
950
|
+
* same-name replacement's) are told from the removed instance's. `webChatRemovals`: once a removal succeeded, the
|
|
951
|
+
* generations and the daemon whose replies are no longer anyone's to show.
|
|
952
|
+
*/
|
|
953
|
+
webChatGenerations = new Map();
|
|
954
|
+
webChatRemovals = new Map();
|
|
955
|
+
/** #1774: a removal under way — its replies' fate waits for its outcome (success: theirs no more; failure: theirs). */
|
|
956
|
+
webChatRemovalsPending = new Map();
|
|
957
|
+
webChatGeneration(name) { return this.webChatGenerations.get(name) ?? 0; }
|
|
958
|
+
/** #1774: who an instance's reply is from, taken when it is sent: the name's generation and its daemon then. */
|
|
959
|
+
webChatIdentity(name) { return { generation: this.webChatGeneration(name), daemon: this.daemons.get(name) }; }
|
|
960
|
+
/** #1774: the reply's instance was not removed (a removal that failed changes nothing). */
|
|
961
|
+
webChatIdentityCurrent(name, id) {
|
|
962
|
+
const removed = this.webChatRemovals.get(name);
|
|
963
|
+
return !removed || (id.generation >= removed.below && (removed.daemon === undefined || id.daemon !== removed.daemon));
|
|
964
|
+
}
|
|
965
|
+
/** #1774: the removal still deciding this reply's fate (it was sent by what is being removed), or null. */
|
|
966
|
+
webChatRemovalPending(name, id) {
|
|
967
|
+
const pending = this.webChatRemovalsPending.get(name);
|
|
968
|
+
return pending && (id.generation < pending.below || (pending.daemon !== undefined && id.daemon === pending.daemon)) ? pending.done : null;
|
|
969
|
+
}
|
|
861
970
|
/** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
|
|
862
971
|
webReplyMaxBytes = REPLY_FILES_MAX_BYTES;
|
|
863
972
|
/** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
|
|
@@ -943,6 +1052,11 @@ export class FleetManager {
|
|
|
943
1052
|
// r4.1 Q3 (b): the card's limits are read from fleet.yaml at each upload (a change applies without a restart).
|
|
944
1053
|
return (this.themeStoreInstance ??= new ThemeStore(this.dataDir, { limits: () => effectiveAppearanceLimits(this.fleetConfig?.web?.appearance).limits, effectsDropped: ids => this.effectLibrary.collectUnreferenced(ids) }));
|
|
945
1054
|
}
|
|
1055
|
+
officeStoreInstance;
|
|
1056
|
+
/** Fleet → Org chart → Office: the offices people made (web-offices.json, beside web-themes.json). */
|
|
1057
|
+
get officeStore() { return (this.officeStoreInstance ??= new OfficeStore(this.dataDir)); }
|
|
1058
|
+
/** How often a person may save the offices (PUT /ui/offices); memory only, as the appearance limiter. */
|
|
1059
|
+
officeWrites = new OfficeWriteLimiter();
|
|
946
1060
|
/** #1629: the theme tools, a person's reset and lock, and the one appearance rate limiter (shared with effects). */
|
|
947
1061
|
get webAppearance() {
|
|
948
1062
|
return (this.webAppearanceService ??= new WebAppearance(this.themeStore, () => this.fleetConfig?.web ?? null, () => { try {
|
|
@@ -1032,6 +1146,7 @@ export class FleetManager {
|
|
|
1032
1146
|
/** #1629 r4 §1: get_web_appearance / preview_web_appearance — the caller's own look, resolved as the page resolves it. */
|
|
1033
1147
|
get appearanceTools() {
|
|
1034
1148
|
return (this.appearanceToolsService ??= new AppearanceTools({
|
|
1149
|
+
canvasWorkerEnabled: () => this.canvasWorkerEnabled(),
|
|
1035
1150
|
frame: (name) => (this.getUiStatus().instances.find(i => i.name === name) ?? { name }),
|
|
1036
1151
|
theme: (name) => {
|
|
1037
1152
|
const r = this.themeStore.get(name);
|
|
@@ -1076,6 +1191,7 @@ export class FleetManager {
|
|
|
1076
1191
|
prefs: this.webPrefs,
|
|
1077
1192
|
ownerCss: this.ownerCss,
|
|
1078
1193
|
effects: this.effectLibrary,
|
|
1194
|
+
canvasWorkerEnabled: () => this.canvasWorkerEnabled(),
|
|
1079
1195
|
context: () => ({ agendVersion: agendVersion(), limits: this.themeStore.limits(), effects: cw1EffectChecker() }),
|
|
1080
1196
|
isKnownInstance: (name) => this.isKnownInstance(name),
|
|
1081
1197
|
prefsIdentity: OPERATOR,
|
|
@@ -1114,6 +1230,11 @@ export class FleetManager {
|
|
|
1114
1230
|
const card = resolveCard(name, humanCards(this.dataDir).get(name), this.themeStore.get(name).theme.card);
|
|
1115
1231
|
return { ...(card.tagline ? { tagline: card.tagline.text } : {}), ...(card.bio ? { bio: card.bio.text } : {}) };
|
|
1116
1232
|
}
|
|
1233
|
+
appIconStore;
|
|
1234
|
+
/** #2308: the person's own app icon (Settings → Web appearance), under the state dir; none set: the bundled one. */
|
|
1235
|
+
get appIcon() {
|
|
1236
|
+
return (this.appIconStore ??= new AppIconStore(this.dataDir));
|
|
1237
|
+
}
|
|
1117
1238
|
ownerCssStore;
|
|
1118
1239
|
/** #1629 r4 §3: each person's own CSS; which version is in force is their `css.active` in the web preferences. */
|
|
1119
1240
|
get ownerCss() {
|
|
@@ -1126,6 +1247,7 @@ export class FleetManager {
|
|
|
1126
1247
|
*/
|
|
1127
1248
|
get webSettings() {
|
|
1128
1249
|
return (this.webSettingsToolsService ??= new WebSettingsTools({
|
|
1250
|
+
canvasWorkerEnabled: () => this.canvasWorkerEnabled(),
|
|
1129
1251
|
prefs: this.webPrefs, ownerCss: this.ownerCss, mods: this.mods, effects: this.effectService,
|
|
1130
1252
|
instances: () => [...Object.keys(this.fleetConfig?.instances ?? {}),
|
|
1131
1253
|
...(this.classicChannels?.getAll() ?? []).map((ch) => ch.instanceName).filter((n) => !Object.hasOwn(this.fleetConfig?.instances ?? {}, n))],
|
|
@@ -1243,9 +1365,11 @@ export class FleetManager {
|
|
|
1243
1365
|
return null;
|
|
1244
1366
|
const record = auth.session, sessions = this.webSessions, exposure = gatewayRequestContext(req);
|
|
1245
1367
|
return { id: record.idHash, label: record.label, source: exposure ? "public_link" : "web_session",
|
|
1368
|
+
localAdmin: () => record.tier === "admin" && record.surface === "local" && !exposure,
|
|
1246
1369
|
current: () => current() && sessions === this.webSessions && !!this.webToken
|
|
1247
1370
|
&& sessions.isCurrent(record, tokenEpoch(this.webToken)) && (!exposure || exposure.isCurrent()) };
|
|
1248
1371
|
}, baseline: () => baselines.read(), snapshot: () => baselines.snapshot(),
|
|
1372
|
+
selfConfirm: (req, id) => this.settingsSelfConfirmation(store, req, id),
|
|
1249
1373
|
job: async (id) => {
|
|
1250
1374
|
const settlement = this.settingsJobSettlements.get(id);
|
|
1251
1375
|
if (!settlement)
|
|
@@ -1260,6 +1384,93 @@ export class FleetManager {
|
|
|
1260
1384
|
this.settingsConfirmation = gate;
|
|
1261
1385
|
return gate;
|
|
1262
1386
|
}
|
|
1387
|
+
/**
|
|
1388
|
+
* #2170: a held change confirmed by the request itself — only when ALL hold, else the chat card or the host CLI, as
|
|
1389
|
+
* before:
|
|
1390
|
+
* - the session is web-verified (#1939: its account confirmed this browser on Discord or Telegram), asked now;
|
|
1391
|
+
* - the change's effect owners are known and on one platform (settingsChatAuthority — the chat card's own rule), the
|
|
1392
|
+
* verified account is on that platform and an admin of every one of them;
|
|
1393
|
+
* - a chat card could have been asked: a connected General on that platform the change does not affect (else the
|
|
1394
|
+
* change goes to the host CLI, as before) — the General that then hears of it;
|
|
1395
|
+
* - and, at execution (store.decide asks `current()` again): still web-verified under the same session and account
|
|
1396
|
+
* (not expired, revoked, signed out or the switch turned off), still those owners' admin, still a General a card
|
|
1397
|
+
* could go to, the fleet unchanged.
|
|
1398
|
+
* What it rests on: the account's one confirmation of this browser (#1939), kept for the session until it is revoked,
|
|
1399
|
+
* expires or signs out — not a fresh confirmation per Apply. Whoever uses that verified session (a browser left signed
|
|
1400
|
+
* in, its cookie) confirms the same way. A public link never qualifies (evaluateWebAuthority refuses it). General
|
|
1401
|
+
* still hears it: a notice, not a step.
|
|
1402
|
+
*/
|
|
1403
|
+
settingsSelfConfirmation(store, req, id) {
|
|
1404
|
+
const auth = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
|
|
1405
|
+
if (auth.kind !== "ok")
|
|
1406
|
+
return null;
|
|
1407
|
+
// #2453 (§0.2): never over the public link — not even a named-link session that is web-verified there.
|
|
1408
|
+
if (gatewayRequestContext(req))
|
|
1409
|
+
return null;
|
|
1410
|
+
// …and never a change that turns the named-link option on: that one needs a General's card or the host.
|
|
1411
|
+
if (!store.selfConfirmable(id))
|
|
1412
|
+
return null;
|
|
1413
|
+
const record = auth.session, gateway = !!gatewayRequestContext(req), peer = webPeerOf(req);
|
|
1414
|
+
const verified = () => {
|
|
1415
|
+
const now = this.webAuthority({ session: record, gateway, peer });
|
|
1416
|
+
// A refusal carries no authority (kind "none"); the handle pins the session, and with it its one account.
|
|
1417
|
+
return now.authority.kind === "web-verified" && now.authority.actor && now.authority.session?.handle === record.handle ? now.authority.actor : null;
|
|
1418
|
+
};
|
|
1419
|
+
const actor = verified();
|
|
1420
|
+
if (!actor)
|
|
1421
|
+
return null;
|
|
1422
|
+
const chat = this.settingsChatAuthority(store, id);
|
|
1423
|
+
if (!chat)
|
|
1424
|
+
return null;
|
|
1425
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
1426
|
+
const own = channels.filter(ch => (ch.id ?? ch.type) === actor.adapter_id);
|
|
1427
|
+
if (own.length !== 1 || own[0].type !== chat.platform || !chat.admin(actor.user_id))
|
|
1428
|
+
return null;
|
|
1429
|
+
const affected = [...store.affectedConnections(id)]; // read now: a finished change no longer has them
|
|
1430
|
+
if (!this.settingsNoticeGenerals(affected, chat.platform).length)
|
|
1431
|
+
return null;
|
|
1432
|
+
const name = this.webPerson({ adapterId: actor.adapter_id, userId: actor.user_id }).name;
|
|
1433
|
+
return {
|
|
1434
|
+
label: t("settings_confirm.web_verified_label", name),
|
|
1435
|
+
// admin() asks chat.current() too; a General a card could go to must still be there (else the host CLI's).
|
|
1436
|
+
current: () => !!verified() && chat.admin(actor.user_id) && this.settingsNoticeGenerals(affected, chat.platform).length > 0,
|
|
1437
|
+
done: view => this.settingsWebConfirmedNotice(view, affected, name, chat.platform),
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
/** #2170: the Generals a chat card could be posted in (promptSettingsChange's rule): connected, on the platform, unaffected. */
|
|
1441
|
+
settingsNoticeGenerals(affected, platform) {
|
|
1442
|
+
const excluded = new Set(affected), found = [];
|
|
1443
|
+
for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
1444
|
+
if (!config.general_topic || !this.daemons.get(name))
|
|
1445
|
+
continue;
|
|
1446
|
+
const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
|
|
1447
|
+
if (!adapterId || !adapter || excluded.has(adapterId) || adapter.type !== platform || this.adapterState.get(adapterId)?.status !== "connected")
|
|
1448
|
+
continue;
|
|
1449
|
+
const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
|
|
1450
|
+
if (chatId && topic)
|
|
1451
|
+
found.push({ adapterId, adapter, chatId, topic });
|
|
1452
|
+
}
|
|
1453
|
+
return found;
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* #2170: General's notice of a change confirmed from the web — the first General a chat card could have been posted
|
|
1457
|
+
* in, plain text, no mentions. Information only: nothing waits on it.
|
|
1458
|
+
*/
|
|
1459
|
+
async settingsWebConfirmedNotice(view, affected, who, platform) {
|
|
1460
|
+
const outcome = view.outcome ? view.outcome.message : view.state;
|
|
1461
|
+
const text = [t("settings_confirm.web_confirmed", who), t("settings_confirm.source", settingsSourceLabel(view.source)),
|
|
1462
|
+
t("settings_confirm.requester", view.requested_by), ...view.summary, t("settings_confirm.outcome", outcome)].join("\n");
|
|
1463
|
+
for (const { adapterId, adapter, chatId, topic } of this.settingsNoticeGenerals(affected, platform)) {
|
|
1464
|
+
try {
|
|
1465
|
+
await adapter.sendText(chatId, text, { threadId: topic, allowedMentions: { parse: [] } });
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
catch {
|
|
1469
|
+
this.logger.info({ id: view.id, adapterId }, "Settings web-confirmation notice unavailable; trying another General");
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
this.logger.info({ id: view.id }, "Settings change confirmed from the web: no General reachable for the notice");
|
|
1473
|
+
}
|
|
1263
1474
|
/** Exact configured effect owners; this does not depend on prompt location. */
|
|
1264
1475
|
settingsChatAuthority(store, id) {
|
|
1265
1476
|
const scope = store.authorityOf(id);
|
|
@@ -1330,9 +1541,10 @@ export class FleetManager {
|
|
|
1330
1541
|
try {
|
|
1331
1542
|
const posted = this.postNonceButtonPromptOrThrow({ prefix: "settings-confirm:", alertType: "clear_confirm",
|
|
1332
1543
|
instanceName: name, adapterId, adapter, chatId, threadId: topic,
|
|
1333
|
-
message:
|
|
1334
|
-
|
|
1335
|
-
|
|
1544
|
+
message: [t("settings_confirm.prompt_title"), t("settings_confirm.source", settingsSourceLabel(view.source)),
|
|
1545
|
+
t("settings_confirm.requester", view.requested_by), ...view.summary].join("\n"),
|
|
1546
|
+
choices: [{ action: "confirm", label: t("settings_confirm.confirm_button") }, { action: "reject", label: t("settings_confirm.reject_button") }],
|
|
1547
|
+
expiredText: t("settings_confirm.no_longer_pending"), timeoutMs: view.remaining_ms,
|
|
1336
1548
|
extra: { pendingChangeId: view.id, confirmationCurrent: current, confirmationAdmin: userId => current() && authority.admin(userId) },
|
|
1337
1549
|
});
|
|
1338
1550
|
void posted.then(nonce => {
|
|
@@ -1343,7 +1555,7 @@ export class FleetManager {
|
|
|
1343
1555
|
return;
|
|
1344
1556
|
this.pendingNonceButtons.delete(nonce);
|
|
1345
1557
|
clearTimeout(entry.timer);
|
|
1346
|
-
void this.retireNonceButtons(entry, entry.messageId ?? "", "
|
|
1558
|
+
void this.retireNonceButtons(entry, entry.messageId ?? "", t("settings_confirm.delivery_expired"));
|
|
1347
1559
|
}, () => { });
|
|
1348
1560
|
const nonce = await withinBudget(posted, postingDeadline);
|
|
1349
1561
|
attached = true;
|
|
@@ -1353,7 +1565,7 @@ export class FleetManager {
|
|
|
1353
1565
|
return;
|
|
1354
1566
|
this.pendingNonceButtons.delete(nonce);
|
|
1355
1567
|
clearTimeout(entry.timer);
|
|
1356
|
-
void this.retireNonceButtons(entry, entry.messageId ?? "", "
|
|
1568
|
+
void this.retireNonceButtons(entry, entry.messageId ?? "", t("settings_confirm.no_longer_pending"));
|
|
1357
1569
|
};
|
|
1358
1570
|
if (!current()) {
|
|
1359
1571
|
retire();
|
|
@@ -1379,14 +1591,14 @@ export class FleetManager {
|
|
|
1379
1591
|
const { entry, action } = result;
|
|
1380
1592
|
try {
|
|
1381
1593
|
const view = await this.settingsGate().store.decide(entry.pendingChangeId, action, {
|
|
1382
|
-
label:
|
|
1594
|
+
label: t("settings_confirm.admin_label", data.userId ?? ""),
|
|
1383
1595
|
current: () => entry.confirmationCurrent?.() === true && this.getAdapterForInstance(entry.instanceName) === entry.adapter
|
|
1384
1596
|
&& !!data.userId && entry.confirmationAdmin?.(data.userId) === true,
|
|
1385
1597
|
});
|
|
1386
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, view.outcome?.message ?? "
|
|
1598
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, view.outcome?.message ?? t("settings_confirm.applying"));
|
|
1387
1599
|
}
|
|
1388
1600
|
catch {
|
|
1389
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, "
|
|
1601
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("settings_confirm.rejected_or_expired"));
|
|
1390
1602
|
}
|
|
1391
1603
|
return true;
|
|
1392
1604
|
}
|
|
@@ -1465,6 +1677,7 @@ export class FleetManager {
|
|
|
1465
1677
|
this.spawnGate = new SpawnGate({
|
|
1466
1678
|
storm: this.stormWindow,
|
|
1467
1679
|
memoryPressure: this.memoryPressure,
|
|
1680
|
+
onStartupMemoryWait: () => this.startupMemoryDeadline?.check(),
|
|
1468
1681
|
concurrency: () => this.spawnConcurrency(),
|
|
1469
1682
|
staggerMs: () => this.fleetConfig?.defaults?.startup?.stagger_delay_ms ?? 500,
|
|
1470
1683
|
});
|
|
@@ -1584,7 +1797,7 @@ export class FleetManager {
|
|
|
1584
1797
|
...(web && by.guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]", as /ui/send
|
|
1585
1798
|
...reactions.meta,
|
|
1586
1799
|
},
|
|
1587
|
-
}, by.humanOrigin, button.label)) !== false;
|
|
1800
|
+
}, by.humanOrigin, button.label, this.startRetryHoldFor(set.instance, inbound))) !== false;
|
|
1588
1801
|
}
|
|
1589
1802
|
finally {
|
|
1590
1803
|
// Both false and throw reopen the choice in the controller. Finish the
|
|
@@ -1633,8 +1846,18 @@ export class FleetManager {
|
|
|
1633
1846
|
}
|
|
1634
1847
|
this.deliveryOutbox = outbox;
|
|
1635
1848
|
this.deliveryOutboxRecovered = true;
|
|
1849
|
+
outbox.on("working_references", (names) => {
|
|
1850
|
+
for (const name of names)
|
|
1851
|
+
this.observeWorkingState(name, "obligations");
|
|
1852
|
+
});
|
|
1636
1853
|
outbox.on("admitted", () => { this.scheduleDeliveryOutboxPump(); this.wakeCoordinator?.kick(); });
|
|
1637
1854
|
outbox.on("state", (event) => {
|
|
1855
|
+
if (event.deliveryId) {
|
|
1856
|
+
const row = outbox.get(event.deliveryId);
|
|
1857
|
+
if (row)
|
|
1858
|
+
for (const name of new Set([row.sourceInstance, row.targetInstance]))
|
|
1859
|
+
this.observeWorkingState(name, "obligations");
|
|
1860
|
+
}
|
|
1638
1861
|
this.scheduleDeliveryOutboxPump();
|
|
1639
1862
|
this.wakeCoordinator?.kick();
|
|
1640
1863
|
if (event.deliveryId && event.state === "delivered") {
|
|
@@ -1655,7 +1878,10 @@ export class FleetManager {
|
|
|
1655
1878
|
: t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
|
|
1656
1879
|
this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state,
|
|
1657
1880
|
cause: row.lastError, ...(silent ? { silentSchedule: true } : {}) }, notice);
|
|
1658
|
-
|
|
1881
|
+
// A self-scheduled silent heartbeat stays log-only, including the
|
|
1882
|
+
// operator alert; another source still receives the normal notice.
|
|
1883
|
+
if (!silent || row.sourceInstance !== row.targetInstance)
|
|
1884
|
+
this.notifyFleetError(notice);
|
|
1659
1885
|
}
|
|
1660
1886
|
}
|
|
1661
1887
|
});
|
|
@@ -1790,8 +2016,19 @@ export class FleetManager {
|
|
|
1790
2016
|
: item),
|
|
1791
2017
|
};
|
|
1792
2018
|
}
|
|
2019
|
+
/** A terminal OOM stop is not an auto-wakeable pause, including after a fleet restart. */
|
|
2020
|
+
sessionOomStopped(name) {
|
|
2021
|
+
return this.daemons.get(name)?.isSessionOomStopped?.() === true || readSessionOomStop(this.getInstanceDir(name)).blocked;
|
|
2022
|
+
}
|
|
2023
|
+
sessionOomStopReason(name) {
|
|
2024
|
+
const state = readSessionOomStop(this.getInstanceDir(name));
|
|
2025
|
+
return state.blocked ? sessionOomStopHint(state)
|
|
2026
|
+
: this.daemons.get(name)?.notAcceptingReason?.() ?? sessionOomStopHint();
|
|
2027
|
+
}
|
|
1793
2028
|
/** #1641: why a target is not taking queued work right now, or null when nothing known holds it. */
|
|
1794
2029
|
deliveryWaitingReason(target) {
|
|
2030
|
+
if (this.sessionOomStopped(target))
|
|
2031
|
+
return this.sessionOomStopReason(target);
|
|
1795
2032
|
if (this.lifecycle.isPaused(target))
|
|
1796
2033
|
return t("delivery.hold_state", "paused");
|
|
1797
2034
|
return this.daemons.get(target)?.notAcceptingReason?.() ?? null;
|
|
@@ -1804,6 +2041,9 @@ export class FleetManager {
|
|
|
1804
2041
|
}
|
|
1805
2042
|
/** Called after a replacement Daemon object is installed in lifecycle.daemons. */
|
|
1806
2043
|
onDaemonReady(name, daemonBootId) {
|
|
2044
|
+
const daemon = this.daemons.get(name);
|
|
2045
|
+
if (daemon?.bootId === daemonBootId)
|
|
2046
|
+
this.listenForWorkingState(name, daemon);
|
|
1807
2047
|
const outbox = this.deliveryOutbox;
|
|
1808
2048
|
if (!outbox)
|
|
1809
2049
|
return;
|
|
@@ -1827,6 +2067,82 @@ export class FleetManager {
|
|
|
1827
2067
|
}
|
|
1828
2068
|
this.scheduleDeliveryOutboxPump();
|
|
1829
2069
|
}
|
|
2070
|
+
workingStateListeners;
|
|
2071
|
+
workingStateErrors;
|
|
2072
|
+
/** Attach only to the published owner. Nothing here sends or resumes work. */
|
|
2073
|
+
listenForWorkingState(name, daemon) {
|
|
2074
|
+
if (typeof daemon.getWorkingStateObservation !== "function")
|
|
2075
|
+
return;
|
|
2076
|
+
this.workingStateListeners ??= new WeakSet();
|
|
2077
|
+
if (this.workingStateListeners.has(daemon))
|
|
2078
|
+
return;
|
|
2079
|
+
this.workingStateListeners.add(daemon);
|
|
2080
|
+
daemon.on("working_state", (event) => {
|
|
2081
|
+
try {
|
|
2082
|
+
if (this.daemons.get(name) !== daemon || !sameInteractionOwner(event.observation.owner, daemon.getInteractionSnapshot().owner))
|
|
2083
|
+
return;
|
|
2084
|
+
this.observeWorkingState(name, event.reason, event.observation);
|
|
2085
|
+
}
|
|
2086
|
+
catch { /* A malformed/stale observation cannot affect the daemon. */ }
|
|
2087
|
+
});
|
|
2088
|
+
this.observeWorkingState(name, "published");
|
|
2089
|
+
}
|
|
2090
|
+
observeWorkingState(name, reason, supplied) {
|
|
2091
|
+
const outbox = this.deliveryOutbox, daemon = this.daemons.get(name);
|
|
2092
|
+
if (!outbox || !daemon || typeof daemon.getWorkingStateObservation !== "function")
|
|
2093
|
+
return;
|
|
2094
|
+
try {
|
|
2095
|
+
const observation = supplied ?? daemon.getWorkingStateObservation();
|
|
2096
|
+
if (!sameInteractionOwner(observation.owner, daemon.getInteractionSnapshot().owner))
|
|
2097
|
+
return;
|
|
2098
|
+
const references = [];
|
|
2099
|
+
const unavailable = [];
|
|
2100
|
+
let totalReferences = 0;
|
|
2101
|
+
const board = this.scheduler?.db.board;
|
|
2102
|
+
if (!board)
|
|
2103
|
+
unavailable.push("tasks");
|
|
2104
|
+
else
|
|
2105
|
+
try {
|
|
2106
|
+
const tasks = board.listCards({ assignee: name, status: ["claimed", "blocked", "review"], limit: 16 });
|
|
2107
|
+
totalReferences += tasks.total;
|
|
2108
|
+
for (const card of tasks.cards)
|
|
2109
|
+
references.push({ kind: "task", id: card.uuid, version: card.version,
|
|
2110
|
+
status: card.status });
|
|
2111
|
+
}
|
|
2112
|
+
catch {
|
|
2113
|
+
unavailable.push("tasks");
|
|
2114
|
+
}
|
|
2115
|
+
try {
|
|
2116
|
+
const obligations = outbox.workingObligations(name);
|
|
2117
|
+
references.push(...obligations.references);
|
|
2118
|
+
totalReferences += obligations.total;
|
|
2119
|
+
}
|
|
2120
|
+
catch {
|
|
2121
|
+
unavailable.push("obligations");
|
|
2122
|
+
}
|
|
2123
|
+
const entry = outbox.observeWorkingState(name, { version: 1, reason, observedAt: Date.now(), observation,
|
|
2124
|
+
lastActivityAt: this.lastActivity.get(name) ?? null, references, totalReferences, unavailable });
|
|
2125
|
+
if (!entry)
|
|
2126
|
+
return;
|
|
2127
|
+
const state = entry.current.observation.executionState ?? "unknown";
|
|
2128
|
+
const counts = { tasks: entry.current.references.filter(r => r.kind === "task").length,
|
|
2129
|
+
owed: entry.current.references.filter(r => r.kind === "owed").length,
|
|
2130
|
+
waiting: entry.current.references.filter(r => r.kind === "waiting").length,
|
|
2131
|
+
background: entry.current.observation.backgroundWork,
|
|
2132
|
+
total: entry.current.totalReferences, unavailable: entry.current.unavailable };
|
|
2133
|
+
this.logger.info({ name, state, reason, revision: entry.revision, ...counts }, "Working-state journal observed");
|
|
2134
|
+
this.eventLog?.insert(name, "working_state", { state, reason, revision: entry.revision, ...counts,
|
|
2135
|
+
message: `Observed ${state}; tasks ${counts.tasks}, replies owed ${counts.owed}, waiting ${counts.waiting}; ${counts.total} references${counts.background === true ? "; background work running" : ""}${unavailable.length ? "; some sources unavailable" : ""}. Journal only; no recovery action.` });
|
|
2136
|
+
this.emitSseEvent("steps_tick", { instance: name });
|
|
2137
|
+
}
|
|
2138
|
+
catch {
|
|
2139
|
+
this.workingStateErrors ??= new Set();
|
|
2140
|
+
if (!this.workingStateErrors.has(name)) {
|
|
2141
|
+
this.workingStateErrors.add(name);
|
|
2142
|
+
this.logger.warn({ name }, "Could not persist working-state observation; delivery and restart behavior unchanged");
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
1830
2146
|
/**
|
|
1831
2147
|
* #926: the two reply-obligation safety nets. Idle owners with an unanswered
|
|
1832
2148
|
* request get one reminder (after the grace); requesters get one overdue
|
|
@@ -2507,6 +2823,11 @@ export class FleetManager {
|
|
|
2507
2823
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
2508
2824
|
this.warnAboutRemovedLoginMode(loaded);
|
|
2509
2825
|
this.enforceGuestSwitch();
|
|
2826
|
+
this.observeWebAuthority(); // #2453 review: a reload's web-authority switches are seen at once
|
|
2827
|
+
// Child tool/guidance readers must follow this fleet's actual config, including --config.
|
|
2828
|
+
// Scope the path to its owning data directory so another inherited home's override cannot enable CW.
|
|
2829
|
+
process.env.AGEND_FLEET_DATA_DIR = resolvePath(this.dataDir);
|
|
2830
|
+
process.env.AGEND_FLEET_CONFIG_PATH = resolvePath(configPath);
|
|
2510
2831
|
return this.fleetConfig;
|
|
2511
2832
|
}
|
|
2512
2833
|
relayLoginModeWarned = false;
|
|
@@ -2686,8 +3007,17 @@ export class FleetManager {
|
|
|
2686
3007
|
this.logger.info(`Received SIGUSR1, initiating full restart (process reload)... ${describeSignalSource(this.dataDir, "SIGUSR1")}`);
|
|
2687
3008
|
this.gracefulShutdownForReload()
|
|
2688
3009
|
.then(() => {
|
|
2689
|
-
|
|
2690
|
-
|
|
3010
|
+
// #2356: under an AgEnD systemd unit a clean exit is a stop (Restart=on-failure); exit so the unit comes back.
|
|
3011
|
+
let ownCgroup = null;
|
|
3012
|
+
try {
|
|
3013
|
+
ownCgroup = readFileSync("/proc/self/cgroup", "utf-8");
|
|
3014
|
+
}
|
|
3015
|
+
catch { /* not Linux */ }
|
|
3016
|
+
const code = fullReloadExitCode(ownCgroup);
|
|
3017
|
+
this.logger.info(code === 0
|
|
3018
|
+
? "Full restart: shutdown complete, exiting for reload"
|
|
3019
|
+
: `Full restart: shutdown complete, exiting ${code} so the systemd unit restarts the fleet (#2356)`);
|
|
3020
|
+
process.exit(code);
|
|
2691
3021
|
})
|
|
2692
3022
|
.catch(err => {
|
|
2693
3023
|
this.logger.error({ err }, "Full restart: graceful shutdown failed");
|
|
@@ -2706,7 +3036,7 @@ export class FleetManager {
|
|
|
2706
3036
|
const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
|
|
2707
3037
|
this.beginUpdateProgress(adapter, chatId, data.channelId, messageId);
|
|
2708
3038
|
}
|
|
2709
|
-
const { spawn } = await import("
|
|
3039
|
+
const { spawn } = await import("./child-process.js");
|
|
2710
3040
|
// Plain `agend update`: the CLI picks the channel from the version it is about to replace (a beta install
|
|
2711
3041
|
// stays on beta). Deciding here, from the package.json next to THIS code, read a source checkout's 1.22.0 as
|
|
2712
3042
|
// "not a beta" and sent a beta install to @latest. #1450 C5: the INSTALLED agend, verified, by absolute path.
|
|
@@ -3100,7 +3430,8 @@ export class FleetManager {
|
|
|
3100
3430
|
instancePresentation(name) {
|
|
3101
3431
|
const execution_state = this.getInstanceExecutionState(name);
|
|
3102
3432
|
const interaction = this.getInstanceInteraction(name);
|
|
3103
|
-
|
|
3433
|
+
const startup_phase = !this.lifecycle.isPaused(name) ? this.startupDashboard?.phase(name) : undefined;
|
|
3434
|
+
return { ...(startup_phase ? { startup_phase } : {}), state: presentationState(execution_state, interaction), execution_state,
|
|
3104
3435
|
interaction, interaction_summary: interactionSummary(interaction) };
|
|
3105
3436
|
}
|
|
3106
3437
|
/**
|
|
@@ -3499,7 +3830,7 @@ export class FleetManager {
|
|
|
3499
3830
|
finish(false);
|
|
3500
3831
|
});
|
|
3501
3832
|
}
|
|
3502
|
-
async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch, noInlineWake = false, stillCurrent) {
|
|
3833
|
+
async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch, noInlineWake = false, stillCurrent, onStartRetryHold) {
|
|
3503
3834
|
if (this.shuttingDown || !this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
|
|
3504
3835
|
return false;
|
|
3505
3836
|
await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
|
|
@@ -3534,9 +3865,11 @@ export class FleetManager {
|
|
|
3534
3865
|
if (this.shuttingDown || !this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
|
|
3535
3866
|
return false;
|
|
3536
3867
|
if (!idle) {
|
|
3537
|
-
|
|
3868
|
+
// #2482: a busy target is the normal case (its CLI queues the message): INFO; a WARN only for a likely anomaly.
|
|
3869
|
+
const log = idleGateTimeoutLog(instanceName, timeoutMs, this.instanceStateCache.get(instanceName), Date.now());
|
|
3870
|
+
this.logger[log.level](log.fields, log.message);
|
|
3538
3871
|
}
|
|
3539
|
-
const sent = await this.sendWhenConnected(instanceName, payload, deliveryEpoch, stillCurrent);
|
|
3872
|
+
const sent = await this.sendWhenConnected(instanceName, payload, deliveryEpoch, stillCurrent, onStartRetryHold);
|
|
3540
3873
|
if (!sent)
|
|
3541
3874
|
return false;
|
|
3542
3875
|
if (this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent)) {
|
|
@@ -3565,8 +3898,11 @@ export class FleetManager {
|
|
|
3565
3898
|
* Ordering is preserved by serialising behind any waiter already queued for this
|
|
3566
3899
|
* instance, *including* when the socket happens to be up: otherwise a message
|
|
3567
3900
|
* arriving after the reconnect could overtake one that has been waiting for it.
|
|
3901
|
+
*
|
|
3902
|
+
* `onStartRetryHold` (#2484, a person's message): an instance in its own start-retry backoff is waited for until that
|
|
3903
|
+
* retry brings it up, however long the backoff, and fails only once the retries give up.
|
|
3568
3904
|
*/
|
|
3569
|
-
async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName), stillCurrent) {
|
|
3905
|
+
async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName), stillCurrent, onStartRetryHold) {
|
|
3570
3906
|
if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
|
|
3571
3907
|
return false;
|
|
3572
3908
|
const queued = this.ipcWaitTails.get(instanceName);
|
|
@@ -3580,7 +3916,7 @@ export class FleetManager {
|
|
|
3580
3916
|
.then(() => {
|
|
3581
3917
|
if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
|
|
3582
3918
|
return false;
|
|
3583
|
-
return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch, stillCurrent);
|
|
3919
|
+
return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch, stillCurrent, onStartRetryHold);
|
|
3584
3920
|
});
|
|
3585
3921
|
// The chain stores a settled-either-way promise so one failed delivery cannot
|
|
3586
3922
|
// wedge every later one, and so `queued` above is safe to await unguarded.
|
|
@@ -3598,9 +3934,10 @@ export class FleetManager {
|
|
|
3598
3934
|
}
|
|
3599
3935
|
}
|
|
3600
3936
|
/** Poll for the instance's IPC to come back, then send. Throws if it does not. */
|
|
3601
|
-
async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName), stillCurrent) {
|
|
3602
|
-
|
|
3937
|
+
async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName), stillCurrent, onStartRetryHold) {
|
|
3938
|
+
let deadline = Date.now() + IPC_RECONNECT_GRACE_MS;
|
|
3603
3939
|
let warned = false;
|
|
3940
|
+
let held = false;
|
|
3604
3941
|
for (;;) {
|
|
3605
3942
|
if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
|
|
3606
3943
|
return false;
|
|
@@ -3609,6 +3946,22 @@ export class FleetManager {
|
|
|
3609
3946
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
3610
3947
|
if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
|
|
3611
3948
|
return true;
|
|
3949
|
+
// #2484: while the instance waits for (or runs) its own start retry, the grace runs from now — so the message is
|
|
3950
|
+
// delivered once a retry brings it up, and fails only after the retries stop (they gave up, or a person stopped
|
|
3951
|
+
// or paused it) and a last grace passes without it.
|
|
3952
|
+
if (onStartRetryHold && this.inStartRetryBackoff(instanceName)) {
|
|
3953
|
+
deadline = Math.max(deadline, Date.now() + IPC_RECONNECT_GRACE_MS);
|
|
3954
|
+
if (!held) {
|
|
3955
|
+
held = true;
|
|
3956
|
+
this.logger.info({ instanceName, retry: this.startupRetries.get(instanceName)?.attempt }, "Instance is waiting for its start retry — holding the message until it is up");
|
|
3957
|
+
try {
|
|
3958
|
+
onStartRetryHold();
|
|
3959
|
+
}
|
|
3960
|
+
catch (err) {
|
|
3961
|
+
this.logger.debug({ err, instanceName }, "Start-retry hold notice failed");
|
|
3962
|
+
}
|
|
3963
|
+
}
|
|
3964
|
+
}
|
|
3612
3965
|
if (Date.now() >= deadline) {
|
|
3613
3966
|
throw new Error(`Instance '${instanceName}' IPC is unavailable`);
|
|
3614
3967
|
}
|
|
@@ -3648,12 +4001,69 @@ export class FleetManager {
|
|
|
3648
4001
|
* #1920 §5.2: a web message's channel copy was sent — it is now what the channel saw. Kept so (and on disk), and sent
|
|
3649
4002
|
* to the guests' streams only: an admin's page already has it, and nothing about it changed for them.
|
|
3650
4003
|
*/
|
|
3651
|
-
webMessageInChannel(instance, messageId) {
|
|
4004
|
+
webMessageInChannel(instance, messageId, copies = []) {
|
|
4005
|
+
// #2305: the copies' own message ids, so an instance's reaction to the web message is put on them too.
|
|
4006
|
+
if (copies.length)
|
|
4007
|
+
this.rememberWebEchoCopies(messageId, copies);
|
|
4008
|
+
// #2281: and where they are, for a later web reply to this message to be a native reply there.
|
|
4009
|
+
const posts = copies.flatMap(c => c.adapterId ? [{ adapterId: c.adapterId, chatId: c.chatId, ...(c.threadId ? { threadId: c.threadId } : {}), messageId: c.messageId }] : []);
|
|
4010
|
+
if (posts.length)
|
|
4011
|
+
this.webChatHistory.addPosts(instance, messageId, posts);
|
|
3652
4012
|
const m = this.webChatHistory.markChannel(instance, messageId);
|
|
3653
4013
|
if (m)
|
|
3654
4014
|
broadcastToGuests(this.sseClients, "message", m, (err) => this.logger.debug({ err }, "SSE client write failed; evicting"));
|
|
3655
4015
|
}
|
|
3656
|
-
|
|
4016
|
+
/** #2305: a web message's channel copies (topic, ClassicBot rooms), newest WEB_ECHO_COPIES_MAX, this process only. */
|
|
4017
|
+
webEchoCopies = new Map();
|
|
4018
|
+
rememberWebEchoCopies(messageId, copies) {
|
|
4019
|
+
const list = [...(this.webEchoCopies.get(messageId) ?? []), ...copies.filter(c => c && typeof c.adapter?.react === "function" && c.messageId)];
|
|
4020
|
+
this.webEchoCopies.delete(messageId);
|
|
4021
|
+
this.webEchoCopies.set(messageId, list);
|
|
4022
|
+
while (this.webEchoCopies.size > WEB_ECHO_COPIES_MAX)
|
|
4023
|
+
this.webEchoCopies.delete(this.webEchoCopies.keys().next().value);
|
|
4024
|
+
}
|
|
4025
|
+
/**
|
|
4026
|
+
* #2305: an instance's reaction to a web chat message (`messageId`, the id it was handed): its minimal reply, as on
|
|
4027
|
+
* the platforms. Kept on the message's row (and on disk), shown live (`reactions` on the stream), and put on the
|
|
4028
|
+
* message's channel copies when this process sent them — in the background, each within WEB_MIRROR_TIMEOUT_MS, so a
|
|
4029
|
+
* slow or rate-limited platform never holds the tool's answer (best effort: a copy's failure or timeout is logged,
|
|
4030
|
+
* never the tool's). `mirrored`: how many copies took it, when that is known.
|
|
4031
|
+
* The tool's errors: an emoji outside the web set or one of the instance's delivery marks (web-reactions.ts), or a
|
|
4032
|
+
* message the chat does not have. An emoji the message already has is a success that changes nothing.
|
|
4033
|
+
*/
|
|
4034
|
+
async reactToWebMessage(instance, messageId, raw) {
|
|
4035
|
+
const problem = webReactionProblem(raw, this.statusEmojiAvoidList(instance));
|
|
4036
|
+
if (problem)
|
|
4037
|
+
return { ok: false, error: problem };
|
|
4038
|
+
const emoji = webReactionEmoji(raw);
|
|
4039
|
+
const r = this.webChatHistory.addReaction(instance, messageId, emoji);
|
|
4040
|
+
if (!r)
|
|
4041
|
+
return { ok: false, error: `react: ${messageId} is not a message in ${instance}'s web chat now (it left the history, or it is from before AgEnD restarted) — answer with reply instead` };
|
|
4042
|
+
if (!r.added)
|
|
4043
|
+
return { ok: true, mirrored: Promise.resolve(0) };
|
|
4044
|
+
this.emitSseEvent("reactions", { instance, boot: r.message.boot, id: r.message.id, reactions: r.message.reactions ?? [] });
|
|
4045
|
+
const mirrorOne = (copy) => {
|
|
4046
|
+
let timer;
|
|
4047
|
+
const timeout = new Promise((_, reject) => {
|
|
4048
|
+
timer = setTimeout(() => reject(new Error(`no answer in ${WEB_MIRROR_TIMEOUT_MS} ms`)), WEB_MIRROR_TIMEOUT_MS);
|
|
4049
|
+
timer.unref?.();
|
|
4050
|
+
});
|
|
4051
|
+
return Promise.race([Promise.resolve().then(() => copy.adapter.react(copy.chatId, copy.messageId, reactionForm(copy.adapter.type, emoji), copy.threadId)), timeout])
|
|
4052
|
+
.then(() => 1, (err) => {
|
|
4053
|
+
this.logger.warn({ err, instance, adapter: copy.adapter.type }, "Reaction on a web message's channel copy failed (shown on the web)");
|
|
4054
|
+
return 0;
|
|
4055
|
+
})
|
|
4056
|
+
.finally(() => clearTimeout(timer));
|
|
4057
|
+
};
|
|
4058
|
+
const mirrored = Promise.all((this.webEchoCopies.get(messageId) ?? []).map(mirrorOne)).then(n => n.reduce((a, b) => a + b, 0));
|
|
4059
|
+
return { ok: true, mirrored };
|
|
4060
|
+
}
|
|
4061
|
+
/** #2305: `copies` collects each room's copy (its message id), for a reaction to the web message. */
|
|
4062
|
+
/**
|
|
4063
|
+
* `reply` (#2281): where the message a web reply quotes is on a platform. A room that has it gets the reply as a
|
|
4064
|
+
* native reply to it (`reply.text`: without the quoted line); every other room gets `text`, as before.
|
|
4065
|
+
*/
|
|
4066
|
+
async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK, copies, reply) {
|
|
3657
4067
|
const guard = this.webChannelEchoGuards.get(instance);
|
|
3658
4068
|
const handled = new Set();
|
|
3659
4069
|
let posted = 0;
|
|
@@ -3683,7 +4093,11 @@ export class FleetManager {
|
|
|
3683
4093
|
try {
|
|
3684
4094
|
if (!adapter?.sendText)
|
|
3685
4095
|
continue;
|
|
3686
|
-
|
|
4096
|
+
// A Telegram room is its chat; a Discord room is the guild's channel (an inbound's thread there).
|
|
4097
|
+
const native = reply?.posts.find(p => p.adapterId === adapter.id && (p.chatId === entry.channelId || p.threadId === entry.channelId));
|
|
4098
|
+
const sent = await adapter.sendText(entry.channelId, native ? reply.text : text, { format: "text", allowedMentions: { parse: [] }, ...(native ? { replyTo: native.messageId } : {}) });
|
|
4099
|
+
if (copies && sent && typeof sent.messageId === "string" && sent.messageId)
|
|
4100
|
+
copies.push({ adapter, adapterId: adapter.id, chatId: entry.channelId, messageId: sent.messageId });
|
|
3687
4101
|
ClassicChannelManager.logMessage(instance, sender, text, new Date());
|
|
3688
4102
|
posted++;
|
|
3689
4103
|
}
|
|
@@ -3933,6 +4347,18 @@ export class FleetManager {
|
|
|
3933
4347
|
this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
|
|
3934
4348
|
}
|
|
3935
4349
|
}
|
|
4350
|
+
/**
|
|
4351
|
+
* #2484: a person's message waits for its instance's own start retry (DeliveryOptions.onStartRetryHold), shown as
|
|
4352
|
+
* queued on the message meanwhile. A ClassicBot room message (no `msg`) has no delivery-status reactions: it only waits.
|
|
4353
|
+
*/
|
|
4354
|
+
startRetryHoldFor(instanceName, msg) {
|
|
4355
|
+
return {
|
|
4356
|
+
onStartRetryHold: () => {
|
|
4357
|
+
if (msg?.chatId && msg.messageId)
|
|
4358
|
+
this.reactMessageStatus(instanceName, msg.chatId, msg.messageId, "queued", msg.threadId || undefined);
|
|
4359
|
+
},
|
|
4360
|
+
};
|
|
4361
|
+
}
|
|
3936
4362
|
/** Synchronous steer/btw handoff, using the same evidence and owner binding. */
|
|
3937
4363
|
sendHumanControl(instanceName, payload, origin, originalBody) {
|
|
3938
4364
|
const ipc = this.instanceIpcClients.get(instanceName);
|
|
@@ -3973,7 +4399,7 @@ export class FleetManager {
|
|
|
3973
4399
|
return false;
|
|
3974
4400
|
this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
|
|
3975
4401
|
}
|
|
3976
|
-
const sent = await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch, options.stillCurrent);
|
|
4402
|
+
const sent = await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch, options.stillCurrent, options.onStartRetryHold);
|
|
3977
4403
|
if (!sent)
|
|
3978
4404
|
return false;
|
|
3979
4405
|
// A cross-instance item arriving before the daemon observes this turn as
|
|
@@ -3984,7 +4410,7 @@ export class FleetManager {
|
|
|
3984
4410
|
return true;
|
|
3985
4411
|
}
|
|
3986
4412
|
const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
|
|
3987
|
-
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch, options.noInlineWake === true, options.stillCurrent));
|
|
4413
|
+
const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch, options.noInlineWake === true, options.stillCurrent, options.onStartRetryHold));
|
|
3988
4414
|
this.idleGatedDeliveryTails.set(instanceName, delivery);
|
|
3989
4415
|
try {
|
|
3990
4416
|
return await delivery;
|
|
@@ -4101,6 +4527,18 @@ export class FleetManager {
|
|
|
4101
4527
|
async dispatchSlashCore(slash, adapterId, adapter) {
|
|
4102
4528
|
// #1569: an alias (/web) is its command (/dashboard) from here on — the door, the table and the handler see one name.
|
|
4103
4529
|
const data = { ...slash, command: canonicalCommand(slash.command) };
|
|
4530
|
+
// #2359: /dashboard stays listed; prefix its reply with the deprecation notice, then run /web's handler.
|
|
4531
|
+
const noteDeprecatedDashboard = () => {
|
|
4532
|
+
if (slash.command !== "dashboard")
|
|
4533
|
+
return;
|
|
4534
|
+
const prefix = `${t("dashboard.deprecated")}\n\n`;
|
|
4535
|
+
const respond = data.respond;
|
|
4536
|
+
data.respond = async (text) => respond(prefix + text);
|
|
4537
|
+
if (data.respondButtons) {
|
|
4538
|
+
const respondButtons = data.respondButtons;
|
|
4539
|
+
data.respondButtons = async (text, choices) => respondButtons(prefix + text, choices);
|
|
4540
|
+
}
|
|
4541
|
+
};
|
|
4104
4542
|
const door = this.slashDoor(data, adapterId);
|
|
4105
4543
|
if ("refusal" in door) {
|
|
4106
4544
|
await data.respond(t(door.refusal)).catch(() => { });
|
|
@@ -4111,6 +4549,7 @@ export class FleetManager {
|
|
|
4111
4549
|
// — before the table, which keeps /web for fleet admins. Per account: it touches no instance binding.
|
|
4112
4550
|
// Decided synchronously, so an admin's /web still reaches the table with no await after the door (#1399).
|
|
4113
4551
|
if (data.command === "dashboard" && this.isGuestWebAsk(adapterId, data.userId)) {
|
|
4552
|
+
noteDeprecatedDashboard();
|
|
4114
4553
|
await this.guestWebCommand({ adapterId, userId: data.userId, username: data.username,
|
|
4115
4554
|
arg: String(data.options?.action ?? "").trim().toLowerCase(), replyHere: text => data.respond(text), replyPrivately: text => data.respond(text) });
|
|
4116
4555
|
return;
|
|
@@ -4139,6 +4578,7 @@ export class FleetManager {
|
|
|
4139
4578
|
await data.respond(reply).catch(() => { });
|
|
4140
4579
|
return;
|
|
4141
4580
|
}
|
|
4581
|
+
noteDeprecatedDashboard();
|
|
4142
4582
|
if (data.command === "start") {
|
|
4143
4583
|
await this.handleClassicStartSlash(data, adapterId);
|
|
4144
4584
|
}
|
|
@@ -4427,7 +4867,7 @@ export class FleetManager {
|
|
|
4427
4867
|
mode: target => this.deliveryWorkerMode(target),
|
|
4428
4868
|
available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
|
|
4429
4869
|
listPending: () => this.deliveryOutbox?.listPending() ?? [],
|
|
4430
|
-
isPaused: target => this.lifecycle.isPaused(target),
|
|
4870
|
+
isPaused: target => !this.sessionOomStopped(target) && this.lifecycle.isPaused(target),
|
|
4431
4871
|
pauseReason: target => readPauseReason(this.getInstanceDir(target)),
|
|
4432
4872
|
isRestarting: target => this.isInstanceRestarting(target),
|
|
4433
4873
|
wake: async (target) => {
|
|
@@ -4439,7 +4879,7 @@ export class FleetManager {
|
|
|
4439
4879
|
warmCap: () => this.fleetConfig?.defaults?.warm_cap ?? 0,
|
|
4440
4880
|
warmOverflow: () => this.fleetConfig?.defaults?.warm_overflow ?? DEFAULT_WARM_OVERFLOW,
|
|
4441
4881
|
wokeAt: target => this.lifecycle.wokeAtFor(target),
|
|
4442
|
-
notAcceptingReason: target => this.daemons.get(target)?.notAcceptingReason?.() ?? null,
|
|
4882
|
+
notAcceptingReason: target => this.sessionOomStopped(target) ? this.sessionOomStopReason(target) : this.daemons.get(target)?.notAcceptingReason?.() ?? null,
|
|
4443
4883
|
notifyTarget: (target, text) => { this.notifyInstanceTopic(target, text); },
|
|
4444
4884
|
notifySender: (source, text) => { this.notifyInstanceTopic(source, text); },
|
|
4445
4885
|
kickPump: () => this.scheduleDeliveryOutboxPump(),
|
|
@@ -4494,7 +4934,12 @@ export class FleetManager {
|
|
|
4494
4934
|
* is exactly the old lifecycle wake; otherwise it goes through the shared
|
|
4495
4935
|
* slot admission and joins any wake already in flight for the target.
|
|
4496
4936
|
*/
|
|
4497
|
-
async explicitWake(name, timeoutMs = 30_000) {
|
|
4937
|
+
async explicitWake(name, timeoutMs = 30_000, retryStoppedSession = false) {
|
|
4938
|
+
if (this.sessionOomStopped(name)) {
|
|
4939
|
+
if (!retryStoppedSession)
|
|
4940
|
+
throw new SessionOomStoppedError(false, readSessionOomStop(this.getInstanceDir(name)));
|
|
4941
|
+
return this.restartSingleInstance(name, { explicit: true });
|
|
4942
|
+
}
|
|
4498
4943
|
const token = await this.acquireWakeSlot(name);
|
|
4499
4944
|
try {
|
|
4500
4945
|
await this.lifecycle.wake(name, timeoutMs);
|
|
@@ -4515,7 +4960,7 @@ export class FleetManager {
|
|
|
4515
4960
|
}
|
|
4516
4961
|
async changeInstancePauseState(name, action) {
|
|
4517
4962
|
if (action === "wake") {
|
|
4518
|
-
await this.explicitWake(name, 30_000);
|
|
4963
|
+
await this.explicitWake(name, 30_000, true);
|
|
4519
4964
|
this.enforceWarmCap(name); // manual wake still respects the fleet warm cap
|
|
4520
4965
|
return "awake";
|
|
4521
4966
|
}
|
|
@@ -4691,6 +5136,11 @@ export class FleetManager {
|
|
|
4691
5136
|
/** Phase 2a: the caller's transition, for a start made from inside a restart or wake. */
|
|
4692
5137
|
transition, execution) {
|
|
4693
5138
|
execution?.assert();
|
|
5139
|
+
if (this.sessionOomStopped(name)) {
|
|
5140
|
+
if (resumePaused)
|
|
5141
|
+
return this.restartSingleInstance(name, { explicit: true });
|
|
5142
|
+
throw new SessionOomStoppedError(false, readSessionOomStop(this.getInstanceDir(name)));
|
|
5143
|
+
}
|
|
4694
5144
|
// CLI single-instance cold starts bypass startAll. Start diagnostics before
|
|
4695
5145
|
// any lifecycle/wake work; a shutdown must not revive the stopped sampler.
|
|
4696
5146
|
if (!this.shuttingDown)
|
|
@@ -4699,7 +5149,7 @@ export class FleetManager {
|
|
|
4699
5149
|
// into a running instance — harmless, but noisy — or race this start).
|
|
4700
5150
|
this.cancelStartupRetry(name);
|
|
4701
5151
|
if (resumePaused && this.lifecycle.isPaused(name)) {
|
|
4702
|
-
await this.explicitWake(name, 30_000);
|
|
5152
|
+
await this.explicitWake(name, 30_000, true);
|
|
4703
5153
|
// A successful wake clears the persisted pause marker and produces a
|
|
4704
5154
|
// fresh instance_state snapshot. Drop any stale process error left by a
|
|
4705
5155
|
// pre-pause crash so /api/fleet and `agend ls` converge on running.
|
|
@@ -4707,7 +5157,7 @@ export class FleetManager {
|
|
|
4707
5157
|
return;
|
|
4708
5158
|
}
|
|
4709
5159
|
if (this.lifecycle.isPaused(name)) {
|
|
4710
|
-
this.logger.
|
|
5160
|
+
this.logger.debug({ name }, "Persisted paused instance — skipping startup"); // #2482: every start and reload
|
|
4711
5161
|
return;
|
|
4712
5162
|
}
|
|
4713
5163
|
if (this.lifecycle.daemons.has(name)) {
|
|
@@ -4719,57 +5169,99 @@ export class FleetManager {
|
|
|
4719
5169
|
if (resumePaused) {
|
|
4720
5170
|
const status = this.getInstanceStatus(name);
|
|
4721
5171
|
if (status === "crashed" || status === "stopped") {
|
|
4722
|
-
await this.restartSingleInstance(name);
|
|
5172
|
+
await this.restartSingleInstance(name, { explicit: true });
|
|
4723
5173
|
return;
|
|
4724
5174
|
}
|
|
4725
5175
|
}
|
|
4726
5176
|
this.logger.info({ name }, "Instance already running, skipping");
|
|
4727
5177
|
return;
|
|
4728
5178
|
}
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
else if (kind === "fleet-topic") {
|
|
4738
|
-
// Workers get only role-eligible on-demand skills. Classic instances are
|
|
4739
|
-
// deliberately excluded: their workspace and conversation lifecycle are
|
|
4740
|
-
// managed independently from fleet-topic workers.
|
|
4741
|
-
try {
|
|
4742
|
-
const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
|
|
4743
|
-
measureSyncWork("fleet.workerSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "worker"));
|
|
5179
|
+
await this.trackStartupInstance(name, async () => {
|
|
5180
|
+
// An explicit start (CLI/API) begins a fresh idle window; boot and
|
|
5181
|
+
// reconcile starts keep the persisted last-inbound seed (Phase 2a).
|
|
5182
|
+
if (resumePaused)
|
|
5183
|
+
this.lifecycle.markActivitySeedNow(name);
|
|
5184
|
+
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
5185
|
+
if (config.general_topic) {
|
|
5186
|
+
this.ensureGeneralInstructions(config.working_directory, backend, name);
|
|
4744
5187
|
}
|
|
4745
|
-
|
|
4746
|
-
//
|
|
4747
|
-
//
|
|
4748
|
-
// fleet
|
|
4749
|
-
|
|
5188
|
+
else if (kind === "fleet-topic") {
|
|
5189
|
+
// Workers get only role-eligible on-demand skills. Classic instances are
|
|
5190
|
+
// deliberately excluded: their workspace and conversation lifecycle are
|
|
5191
|
+
// managed independently from fleet-topic workers.
|
|
5192
|
+
try {
|
|
5193
|
+
const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
|
|
5194
|
+
measureSyncWork("fleet.workerSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "worker"));
|
|
5195
|
+
}
|
|
5196
|
+
catch (err) {
|
|
5197
|
+
// Skill publishing is additive. A read-only or temporarily unavailable
|
|
5198
|
+
// workspace must not turn an otherwise valid worker startup into a
|
|
5199
|
+
// fleet outage.
|
|
5200
|
+
this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
|
|
5201
|
+
}
|
|
4750
5202
|
}
|
|
5203
|
+
else if (kind === "classic") {
|
|
5204
|
+
// #2163: a Classic bot gets only the skills marked `classic` (its own look, the dashboard) — never the fleet's
|
|
5205
|
+
// coordination playbooks (#557 kept those out of end-user bots) — and only into a workspace AgEnD manages: the
|
|
5206
|
+
// room's own folder under <AGEND_HOME>/workspaces/. Anywhere else, nothing is written; `agend-agent skill <name>`
|
|
5207
|
+
// reads any bundled skill on demand.
|
|
5208
|
+
try {
|
|
5209
|
+
const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
|
|
5210
|
+
if (insideManagedWorkspaces(skillsWorkDir))
|
|
5211
|
+
measureSyncWork("fleet.classicSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "classic"));
|
|
5212
|
+
else
|
|
5213
|
+
this.logger.debug({ name, skillsWorkDir }, "Classic workspace is not AgEnD-managed — no skills published");
|
|
5214
|
+
}
|
|
5215
|
+
catch (err) {
|
|
5216
|
+
this.logger.warn({ err, name, backend }, "Failed to sync classic skills — continuing startup");
|
|
5217
|
+
}
|
|
5218
|
+
}
|
|
5219
|
+
const receipt = execution ? this.classicSettingsOwners.get(execution) : undefined;
|
|
5220
|
+
const identity = { kind, backend, model: this.resolveInstanceModel(name).display };
|
|
5221
|
+
if (receipt) {
|
|
5222
|
+
await this.lifecycle.start(name, config, topicMode, identity, transition, execution, daemon => {
|
|
5223
|
+
// Only this transition's actual publication can acquire its replacement.
|
|
5224
|
+
receipt.daemon = daemon;
|
|
5225
|
+
});
|
|
5226
|
+
}
|
|
5227
|
+
else
|
|
5228
|
+
await this.lifecycle.start(name, config, topicMode, identity, transition, execution);
|
|
5229
|
+
execution?.assert();
|
|
5230
|
+
// Only clear a stale process status after a real start succeeded. Clearing
|
|
5231
|
+
// it before lifecycle.start() can turn a crash-loop daemon's dead pane into
|
|
5232
|
+
// a falsely running instance when lifecycle.start() returns early.
|
|
5233
|
+
this.instanceProcessStatus.delete(name);
|
|
5234
|
+
// Recovery intent belongs to Daemon.start. lifecycle.start can return
|
|
5235
|
+
// before reaching it, so an await here is not proof the marker was read.
|
|
5236
|
+
// Auto-connect IPC — daemon.start() ensures socket is ready before resolving
|
|
5237
|
+
await this.connectIpcToInstance(name);
|
|
5238
|
+
execution?.assert();
|
|
5239
|
+
this.requestDiscordUsagePresenceRefresh();
|
|
5240
|
+
});
|
|
5241
|
+
}
|
|
5242
|
+
/** A boot's observation cannot settle a replacement boot or a newer attempt of the same instance. */
|
|
5243
|
+
async trackStartupInstance(name, start) {
|
|
5244
|
+
const boot = this.startupDashboard;
|
|
5245
|
+
const owner = boot?.begin(name);
|
|
5246
|
+
if (owner)
|
|
5247
|
+
this.publishStartupStatus();
|
|
5248
|
+
let ready = false;
|
|
5249
|
+
try {
|
|
5250
|
+
await start();
|
|
5251
|
+
ready = this.daemons.has(name);
|
|
4751
5252
|
}
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
5253
|
+
finally {
|
|
5254
|
+
if (boot && owner && this.startupDashboard === boot && boot.finish(name, owner, ready))
|
|
5255
|
+
this.publishStartupStatus();
|
|
5256
|
+
}
|
|
5257
|
+
}
|
|
5258
|
+
publishStartupStatus() {
|
|
5259
|
+
try {
|
|
5260
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
5261
|
+
}
|
|
5262
|
+
catch (err) {
|
|
5263
|
+
this.logger.debug({ err }, "Startup status unavailable; the next status frame retries");
|
|
4759
5264
|
}
|
|
4760
|
-
else
|
|
4761
|
-
await this.lifecycle.start(name, config, topicMode, identity, transition, execution);
|
|
4762
|
-
execution?.assert();
|
|
4763
|
-
// Only clear a stale process status after a real start succeeded. Clearing
|
|
4764
|
-
// it before lifecycle.start() can turn a crash-loop daemon's dead pane into
|
|
4765
|
-
// a falsely running instance when lifecycle.start() returns early.
|
|
4766
|
-
this.instanceProcessStatus.delete(name);
|
|
4767
|
-
// Recovery intent belongs to Daemon.start. lifecycle.start can return
|
|
4768
|
-
// before reaching it, so an await here is not proof the marker was read.
|
|
4769
|
-
// Auto-connect IPC — daemon.start() ensures socket is ready before resolving
|
|
4770
|
-
await this.connectIpcToInstance(name);
|
|
4771
|
-
execution?.assert();
|
|
4772
|
-
this.requestDiscordUsagePresenceRefresh();
|
|
4773
5265
|
}
|
|
4774
5266
|
/** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
|
|
4775
5267
|
async startPersistedPausedInstance(name, transition) {
|
|
@@ -4787,8 +5279,8 @@ export class FleetManager {
|
|
|
4787
5279
|
}
|
|
4788
5280
|
/**
|
|
4789
5281
|
* Start instances with configurable concurrency and stagger delay.
|
|
4790
|
-
* Instances sharing the same working_directory are serialized
|
|
4791
|
-
*
|
|
5282
|
+
* Instances sharing the same working_directory are serialized to avoid
|
|
5283
|
+
* config file races. Stagger applies between outer lifecycle admissions.
|
|
4792
5284
|
* TODO: per-instance startup timeout (existing issue, not introduced here)
|
|
4793
5285
|
*/
|
|
4794
5286
|
async startInstancesWithConcurrency(entries, topicMode, onReady) {
|
|
@@ -4810,15 +5302,30 @@ export class FleetManager {
|
|
|
4810
5302
|
totalInstances: runnableEntries.length,
|
|
4811
5303
|
}, "Adaptive startup concurrency");
|
|
4812
5304
|
}
|
|
5305
|
+
const startedAt = performance.now();
|
|
5306
|
+
let active = 0, peakConcurrentStarts = 0, readyInstances = 0;
|
|
4813
5307
|
await Promise.all(runnableEntries.map(([name, config]) => this.spawnGate.run({
|
|
4814
5308
|
instanceName: name,
|
|
4815
5309
|
workingDirectory: config.working_directory,
|
|
4816
5310
|
reason: "startup",
|
|
4817
5311
|
stage: "lifecycle",
|
|
4818
5312
|
}, async () => {
|
|
4819
|
-
|
|
4820
|
-
|
|
5313
|
+
peakConcurrentStarts = Math.max(peakConcurrentStarts, ++active);
|
|
5314
|
+
try {
|
|
5315
|
+
if (await this.startInstanceUnattended(name, config, topicMode, "instance")) {
|
|
5316
|
+
readyInstances++;
|
|
5317
|
+
onReady?.(name);
|
|
5318
|
+
}
|
|
5319
|
+
}
|
|
5320
|
+
finally {
|
|
5321
|
+
active--;
|
|
5322
|
+
}
|
|
4821
5323
|
})));
|
|
5324
|
+
this.logger.info({
|
|
5325
|
+
totalInstances: runnableEntries.length, readyInstances,
|
|
5326
|
+
elapsedSeconds: Math.round((performance.now() - startedAt) / 100) / 10,
|
|
5327
|
+
peakConcurrentStarts,
|
|
5328
|
+
}, "Fleet startup batch completed");
|
|
4822
5329
|
}
|
|
4823
5330
|
/**
|
|
4824
5331
|
* Start from an UNATTENDED path (fleet startup, full restart, config
|
|
@@ -4837,6 +5344,8 @@ export class FleetManager {
|
|
|
4837
5344
|
// outcome. Retrying would start an instance the operator just stopped.
|
|
4838
5345
|
if (err instanceof SupersededStartError)
|
|
4839
5346
|
return false;
|
|
5347
|
+
if (err instanceof SessionOomStoppedError)
|
|
5348
|
+
await this.notifySessionOomStop(name);
|
|
4840
5349
|
if (this.stopOnUnsupportedCli(name, err, what))
|
|
4841
5350
|
return false;
|
|
4842
5351
|
this.logger.error({ err, name }, `Failed to start ${what}`);
|
|
@@ -4857,6 +5366,11 @@ export class FleetManager {
|
|
|
4857
5366
|
scheduleStartupRetry(name, attempt) {
|
|
4858
5367
|
if (this.shuttingDown)
|
|
4859
5368
|
return;
|
|
5369
|
+
if (this.sessionOomStopped(name)) {
|
|
5370
|
+
this.instanceProcessStatus.set(name, "stopped");
|
|
5371
|
+
this.logger.warn({ name }, this.sessionOomStopReason(name));
|
|
5372
|
+
return;
|
|
5373
|
+
}
|
|
4860
5374
|
if (this.startupRetries.has(name))
|
|
4861
5375
|
return;
|
|
4862
5376
|
const backoff = FleetManager.STARTUP_RETRY_BACKOFF_MS;
|
|
@@ -4884,6 +5398,12 @@ export class FleetManager {
|
|
|
4884
5398
|
this.startupRetries.delete(name);
|
|
4885
5399
|
this.logger.info({ name }, "Pending automatic startup retry cancelled");
|
|
4886
5400
|
}
|
|
5401
|
+
/** #2484: the instance's own start retry is scheduled or running, and no person paused it (a stop cancels the retry). */
|
|
5402
|
+
inStartRetryBackoff(name) {
|
|
5403
|
+
if (this.shuttingDown || this.lifecycle.isPaused(name))
|
|
5404
|
+
return false;
|
|
5405
|
+
return this.startupRetries.has(name) || this.startupRetriesRunning.has(name);
|
|
5406
|
+
}
|
|
4887
5407
|
/** Pending automatic retry, if any (status display / tests). */
|
|
4888
5408
|
pendingStartupRetry(name) {
|
|
4889
5409
|
const pending = this.startupRetries.get(name);
|
|
@@ -4934,6 +5454,8 @@ export class FleetManager {
|
|
|
4934
5454
|
this.startupRetries.delete(name);
|
|
4935
5455
|
if (this.shuttingDown)
|
|
4936
5456
|
return;
|
|
5457
|
+
if (this.sessionOomStopped(name))
|
|
5458
|
+
return;
|
|
4937
5459
|
if (!this.isConfiguredInstance(name))
|
|
4938
5460
|
return; // removed from fleet.yaml / classic channel meanwhile
|
|
4939
5461
|
if (this.lifecycle.isPaused(name) || this.daemons.has(name))
|
|
@@ -4949,6 +5471,7 @@ export class FleetManager {
|
|
|
4949
5471
|
}
|
|
4950
5472
|
const topicMode = this.fleetConfig?.channel?.mode === "topic"
|
|
4951
5473
|
|| !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
|
|
5474
|
+
this.startupRetriesRunning.add(name);
|
|
4952
5475
|
try {
|
|
4953
5476
|
await this.spawnGate.run({
|
|
4954
5477
|
instanceName: name,
|
|
@@ -4970,6 +5493,9 @@ export class FleetManager {
|
|
|
4970
5493
|
this.logger.error({ err, name, attempt: attempt + 1 }, "Automatic startup retry failed");
|
|
4971
5494
|
this.scheduleStartupRetry(name, attempt + 1);
|
|
4972
5495
|
}
|
|
5496
|
+
finally {
|
|
5497
|
+
this.startupRetriesRunning.delete(name);
|
|
5498
|
+
}
|
|
4973
5499
|
}
|
|
4974
5500
|
/** Fleet-topic (fleet.yaml) or ClassicBot (classic channel) — both are retryable; anything else is gone. */
|
|
4975
5501
|
isConfiguredInstance(name) {
|
|
@@ -5244,34 +5770,18 @@ export class FleetManager {
|
|
|
5244
5770
|
return this.adapterState.get(adapterId)?.status === "connected" ? adapter : undefined;
|
|
5245
5771
|
}
|
|
5246
5772
|
/** Last-resort completion after RestartProgress could not deliver to its
|
|
5247
|
-
* adopted target
|
|
5773
|
+
* adopted target (or had no message: a small fleet, a failed first post). It
|
|
5774
|
+
* never wakes a not-ready gateway. #2213: an outage at fleet start no longer
|
|
5775
|
+
* loses it — bounded at startup, then retried in the background once the
|
|
5776
|
+
* adapter is ready again, never posted twice after an uncertain attempt. */
|
|
5248
5777
|
async sendFleetStartCompletionFallback(chatId, text, threadId) {
|
|
5249
5778
|
const adapterId = this.getPrimaryAdapterId();
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
this.logger.error({ adapterId }, "Fleet start completion fallback skipped because the primary adapter is not ready");
|
|
5779
|
+
if (!adapterId) {
|
|
5780
|
+
this.logger.error({ adapterId }, "Fleet start completion fallback skipped because there is no primary adapter");
|
|
5253
5781
|
return false;
|
|
5254
5782
|
}
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
timer = setTimeout(() => resolve({ status: "timeout" }), RESTART_PROGRESS_TERMINAL_TIMEOUT_MS);
|
|
5258
|
-
timer.unref?.();
|
|
5259
|
-
});
|
|
5260
|
-
const delivery = Promise.resolve()
|
|
5261
|
-
.then(() => adapter.sendText(chatId, text, { threadId }))
|
|
5262
|
-
.then(() => ({ status: "sent" }), err => ({ status: "failed", err }));
|
|
5263
|
-
const result = await Promise.race([delivery, timeout]);
|
|
5264
|
-
if (timer)
|
|
5265
|
-
clearTimeout(timer);
|
|
5266
|
-
if (result.status === "sent")
|
|
5267
|
-
return true;
|
|
5268
|
-
if (result.status === "failed") {
|
|
5269
|
-
this.logger.error({ err: result.err }, "Failed to send fleet start completion fallback");
|
|
5270
|
-
}
|
|
5271
|
-
else {
|
|
5272
|
-
this.logger.error({ timeout_ms: RESTART_PROGRESS_TERMINAL_TIMEOUT_MS }, "Timed out sending fleet start completion fallback");
|
|
5273
|
-
}
|
|
5274
|
-
return false;
|
|
5783
|
+
const poster = new RestartProgress(0, Date.now(), this.logger);
|
|
5784
|
+
return poster.postCompletion({ resolveAdapter: () => this.readyProgressAdapter(adapterId), chatId, threadId }, text);
|
|
5275
5785
|
}
|
|
5276
5786
|
async stopInstance(name, transition) {
|
|
5277
5787
|
this.explicitStopGeneration.set(name, (this.explicitStopGeneration.get(name) ?? 0) + 1);
|
|
@@ -5311,12 +5821,29 @@ export class FleetManager {
|
|
|
5311
5821
|
}
|
|
5312
5822
|
/** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
|
|
5313
5823
|
async restartSingleInstance(name, opts) {
|
|
5824
|
+
if (opts?.recoveryCurrent && !opts.recoveryCurrent())
|
|
5825
|
+
throw new Error(t("exit.expired", name));
|
|
5826
|
+
const retry = opts?.explicit ? opts.sessionOomRetry ?? captureSessionOomRetry(this.getInstanceDir(name)) : undefined;
|
|
5827
|
+
if (opts?.sessionOomRetry) {
|
|
5828
|
+
const current = captureSessionOomRetry(this.getInstanceDir(name));
|
|
5829
|
+
// An unclear-cause stop intentionally has no tombstone. Only its live
|
|
5830
|
+
// launch fence can authorize an absent-marker receipt; positive stops
|
|
5831
|
+
// still require the offered durable revision.
|
|
5832
|
+
if ((!retry?.revision && !opts.recoveryCurrent) || current.revision !== retry?.revision || current.legacyRevision !== retry?.legacyRevision)
|
|
5833
|
+
throw new SessionOomStoppedError(true);
|
|
5834
|
+
}
|
|
5835
|
+
if (!opts?.explicit && this.sessionOomStopped(name))
|
|
5836
|
+
throw new SessionOomStoppedError(false, readSessionOomStop(this.getInstanceDir(name)));
|
|
5314
5837
|
// One restart at a time per instance. Multiple sources can ask concurrently
|
|
5315
5838
|
// (MCP revival, /restart, pty_error, model failover); a second stop/start
|
|
5316
5839
|
// interleaved with the first tears down the window the first just created.
|
|
5317
5840
|
// Later callers join the in-flight restart instead — its opts win.
|
|
5318
5841
|
const inFlight = this.restartsInFlight.get(name);
|
|
5319
5842
|
if (inFlight) {
|
|
5843
|
+
// Do not let a late explicit caller clear a stop belonging to the
|
|
5844
|
+
// automatic flight it would join. That action must be retried afterwards.
|
|
5845
|
+
if (retry && this.sessionOomStopped(name))
|
|
5846
|
+
throw new SessionOomStoppedError(true);
|
|
5320
5847
|
this.logger.info({ name }, "restartSingleInstance: joining the restart already in flight");
|
|
5321
5848
|
// #1753: a credential-profile switch joining it still needs its conversation carried in that restart's stop →
|
|
5322
5849
|
// start gap: handed to the restart while it has not reached the gap (and carries nothing else); otherwise not
|
|
@@ -5343,7 +5870,7 @@ export class FleetManager {
|
|
|
5343
5870
|
workingDirectory,
|
|
5344
5871
|
reason: "restart",
|
|
5345
5872
|
stage: "lifecycle",
|
|
5346
|
-
}, () => this.doRestartSingleInstance(name, opts, transition)))
|
|
5873
|
+
}, () => this.doRestartSingleInstance(name, opts, transition, retry)))
|
|
5347
5874
|
.finally(() => {
|
|
5348
5875
|
this.restartsInFlight.delete(name);
|
|
5349
5876
|
if (this.restartGaps.get(name) === gap)
|
|
@@ -5384,7 +5911,7 @@ export class FleetManager {
|
|
|
5384
5911
|
this.lifecycle.clearActivitySeedNow(name);
|
|
5385
5912
|
}
|
|
5386
5913
|
}
|
|
5387
|
-
async doRestartSingleInstance(name, opts, transition) {
|
|
5914
|
+
async doRestartSingleInstance(name, opts, transition, retry) {
|
|
5388
5915
|
if (this.configPath) {
|
|
5389
5916
|
this.loadConfig(this.configPath);
|
|
5390
5917
|
this.routing.rebuild(this.fleetConfig);
|
|
@@ -5402,7 +5929,7 @@ export class FleetManager {
|
|
|
5402
5929
|
if (!wasPaused)
|
|
5403
5930
|
this.restartsHoldingSlot.add(name);
|
|
5404
5931
|
try {
|
|
5405
|
-
await this.doRestartWithSlot(name, opts, transition, config, wasPaused);
|
|
5932
|
+
await this.doRestartWithSlot(name, opts, transition, config, wasPaused, retry);
|
|
5406
5933
|
// An explicit restart of a paused instance is an operator wake (§1.6):
|
|
5407
5934
|
// its success lifts the coordinator's park/backoff (the level stays).
|
|
5408
5935
|
// Automatic restarts never do.
|
|
@@ -5414,7 +5941,7 @@ export class FleetManager {
|
|
|
5414
5941
|
this.releaseWakeSlot(name, slot);
|
|
5415
5942
|
}
|
|
5416
5943
|
}
|
|
5417
|
-
async doRestartWithSlot(name, opts, transition, config, wasPaused) {
|
|
5944
|
+
async doRestartWithSlot(name, opts, transition, config, wasPaused, retry) {
|
|
5418
5945
|
const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
|
|
5419
5946
|
const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
|
|
5420
5947
|
// #1753: the directory the CLI being stopped was launched in — its conversation is under that directory's key.
|
|
@@ -5422,6 +5949,12 @@ export class FleetManager {
|
|
|
5422
5949
|
// Between the stop and the start: the old CLI's transcript is complete, the new one has not looked yet.
|
|
5423
5950
|
/** `launching`: the login the start will use — read only when a switch is to be carried. */
|
|
5424
5951
|
const betweenStopAndStart = async (launching) => {
|
|
5952
|
+
// Clearing is synchronous, after the old daemon is stopped and inside
|
|
5953
|
+
// this action's serialized transition; an unseen later stop is retained.
|
|
5954
|
+
if (retry)
|
|
5955
|
+
clearSessionOomStopForRetry(this.getInstanceDir(name), retry);
|
|
5956
|
+
else
|
|
5957
|
+
assertNoSessionOomStop(this.getInstanceDir(name));
|
|
5425
5958
|
// #1753: this restart's own carry, or one a caller joined it with before this point (restartSingleInstance).
|
|
5426
5959
|
const gap = this.restartGaps.get(name);
|
|
5427
5960
|
const carry = gap?.carry ?? opts?.carry;
|
|
@@ -5707,11 +6240,16 @@ export class FleetManager {
|
|
|
5707
6240
|
dataDir: this.dataDir, web: () => this.fleetConfig?.web,
|
|
5708
6241
|
permitted: owner => this.publicOwnerCurrent(owner),
|
|
5709
6242
|
reserve: id => this.getTunnelLane().reserve("dashboard", id),
|
|
5710
|
-
createGateway: (exposureId, current, open, failed) => createPublicWebGateway({ exposureId, isCurrent: current, isOpen: open, onError: failed,
|
|
6243
|
+
createGateway: (exposureId, current, open, failed, named) => createPublicWebGateway({ ...named, exposureId, isCurrent: current, isOpen: open, onError: failed,
|
|
5711
6244
|
dispatch: (req, res) => this.dispatchWebHttp(req, res, this.fleetConfig?.health_port ?? 19280) }),
|
|
5712
6245
|
revoke: id => {
|
|
5713
6246
|
this.webLoginCodes?.revokeAudience(id);
|
|
5714
6247
|
this.webGuestLoginCodes?.revokeAudience(id);
|
|
6248
|
+
// #2453 (§0.2): the link's sessions end, and the authorizations kept for them go with them (housekeeping: their
|
|
6249
|
+
// handles can never match again).
|
|
6250
|
+
const ended = new Set(this.webSessions?.list(undefined, r => r.exposureId === id).map(r => r.handle) ?? []);
|
|
6251
|
+
if (ended.size)
|
|
6252
|
+
this.sessionAuthorizationStore()?.prune(a => !ended.has(a.handle));
|
|
5715
6253
|
const result = this.webSessions?.revokeExposure(id);
|
|
5716
6254
|
if (result && !result.durable)
|
|
5717
6255
|
this.logger.warn("Public session revocation could not be saved; exposure remains closed");
|
|
@@ -5732,6 +6270,66 @@ export class FleetManager {
|
|
|
5732
6270
|
},
|
|
5733
6271
|
});
|
|
5734
6272
|
}
|
|
6273
|
+
publicLinkAutoOpen = null;
|
|
6274
|
+
/**
|
|
6275
|
+
* #named-link-ttl: the admin who last opened a named-tunnel link from /web, with the live binding — only while they
|
|
6276
|
+
* still may own one (publicOwnerCurrent: a fleet admin of a live bot, the same General). Else null.
|
|
6277
|
+
*/
|
|
6278
|
+
rememberedPublicOwner() {
|
|
6279
|
+
const saved = readPublicLinkOwner(this.dataDir);
|
|
6280
|
+
if (!saved)
|
|
6281
|
+
return null;
|
|
6282
|
+
const owner = { ...saved, binding: this.adapters.get(saved.adapterId) };
|
|
6283
|
+
return this.publicOwnerCurrent(owner) ? owner : null;
|
|
6284
|
+
}
|
|
6285
|
+
/** A named-tunnel link was privately delivered: auto_open reopens it for this owner after a restart. */
|
|
6286
|
+
rememberPublicLinkOwner(owner) {
|
|
6287
|
+
if (!publicLinkNamedHostname(this.fleetConfig?.web))
|
|
6288
|
+
return;
|
|
6289
|
+
try {
|
|
6290
|
+
writePublicLinkOwner(this.dataDir, owner);
|
|
6291
|
+
}
|
|
6292
|
+
catch (err) {
|
|
6293
|
+
this.logger.warn({ err: err?.message }, "Could not remember the public link's owner; auto_open will not reopen it");
|
|
6294
|
+
}
|
|
6295
|
+
}
|
|
6296
|
+
/** Closed on purpose (Close, /web revoke): a restart must not reopen it. The next /web remembers again. */
|
|
6297
|
+
forgetPublicLinkOwner() {
|
|
6298
|
+
try {
|
|
6299
|
+
clearPublicLinkOwner(this.dataDir);
|
|
6300
|
+
}
|
|
6301
|
+
catch (err) {
|
|
6302
|
+
this.logger.warn({ err: err?.message }, "Could not forget the public link's owner");
|
|
6303
|
+
}
|
|
6304
|
+
}
|
|
6305
|
+
/**
|
|
6306
|
+
* #named-link-ttl: `web.public_link.auto_open` — once per start, after the web listener and the chat connections are
|
|
6307
|
+
* up. Opens the named link with nothing posted (sign-in still needs a code from /web or a returning device); a final
|
|
6308
|
+
* failure goes to its owner once, by DM, drawn as a failed /web is.
|
|
6309
|
+
*/
|
|
6310
|
+
startPublicLinkAutoOpen() {
|
|
6311
|
+
this.publicLinkAutoOpen?.stop();
|
|
6312
|
+
const runner = this.publicLinkAutoOpen = new PublicLinkAutoOpen({
|
|
6313
|
+
web: () => this.fleetConfig?.web,
|
|
6314
|
+
ready: () => !this.shuttingDown && this.healthServerListening && this.publicLinkAutoOpen === runner,
|
|
6315
|
+
owner: () => this.rememberedPublicOwner(),
|
|
6316
|
+
open: (owner, onProgress) => this.getPublicWebLink().openUnattended(owner, onProgress),
|
|
6317
|
+
notifyFailed: async (owner, last) => {
|
|
6318
|
+
const adapter = owner.binding;
|
|
6319
|
+
if (this.shuttingDown || !adapter?.sendDirect || !this.publicOwnerCurrent(owner))
|
|
6320
|
+
return;
|
|
6321
|
+
const outcome = t("dashboard.public_auto_failed");
|
|
6322
|
+
try {
|
|
6323
|
+
await withinBudget(adapter.sendDirect(owner.userId, last ? renderPublicLinkProgress(last, performance.now(), outcome) : outcome, { disablePreview: true }), performance.now() + 5_000);
|
|
6324
|
+
}
|
|
6325
|
+
catch (err) {
|
|
6326
|
+
this.logDashboardDmFailure(err);
|
|
6327
|
+
}
|
|
6328
|
+
},
|
|
6329
|
+
log: (event, fields) => this.logger.info({ event, ...fields }, "Public web link"),
|
|
6330
|
+
});
|
|
6331
|
+
runner.start();
|
|
6332
|
+
}
|
|
5735
6333
|
getPublicWebStatus() {
|
|
5736
6334
|
return this.publicWebLink?.status() ?? { state: "closed" };
|
|
5737
6335
|
}
|
|
@@ -5915,8 +6513,9 @@ export class FleetManager {
|
|
|
5915
6513
|
// Show the action before any link exists. TG's Start instructions belong to confirmed DM refusal below.
|
|
5916
6514
|
const menuText = this.topicCommands.getDashboardMenuText()
|
|
5917
6515
|
+ (publicAllowed ? "\n" + t("dashboard.public_risk") : "")
|
|
5918
|
-
+ (status.state
|
|
5919
|
-
|
|
6516
|
+
+ (status.state === "closed" ? "" : status.noExpiry ? "\n" + t("dashboard.public_status_no_expiry", status.state)
|
|
6517
|
+
: "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
|
|
6518
|
+
+ (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : ""));
|
|
5920
6519
|
const choices = [{ action: "local", label: t("dashboard.local_signin") },
|
|
5921
6520
|
...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
|
|
5922
6521
|
...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
|
|
@@ -5963,6 +6562,7 @@ export class FleetManager {
|
|
|
5963
6562
|
if (!entry.publicExposureId)
|
|
5964
6563
|
return true;
|
|
5965
6564
|
const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
|
|
6565
|
+
this.forgetPublicLinkOwner();
|
|
5966
6566
|
if (result?.confirmed === false)
|
|
5967
6567
|
data.ack?.(t("dashboard.public_cleanup"));
|
|
5968
6568
|
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"), data.editClicked);
|
|
@@ -5993,10 +6593,11 @@ export class FleetManager {
|
|
|
5993
6593
|
return false;
|
|
5994
6594
|
this.initializeWebSessions();
|
|
5995
6595
|
const issued = this.webLoginCodes.issue({ epoch: tokenEpoch(token), audience: exposureId, owner });
|
|
5996
|
-
// Only a public link has a link expiry; the code's five-minute
|
|
6596
|
+
// Only a public link has a link expiry (null: none — a named tunnel's ttl_minutes 0); the code's five-minute
|
|
6597
|
+
// lifetime applies to either packet.
|
|
5997
6598
|
const minutes = Math.round(LOGIN_CODE_TTL_MS / 60_000);
|
|
5998
6599
|
const text = expiresAt !== undefined
|
|
5999
|
-
? t("dashboard.private_public", url, issued.display, minutes, formatFleetTime(expiresAt))
|
|
6600
|
+
? t("dashboard.private_public", url, issued.display, minutes, expiresAt === null ? t("dashboard.public_no_expiry") : formatFleetTime(expiresAt))
|
|
6000
6601
|
: t("dashboard.private_local", url, issued.display, minutes);
|
|
6001
6602
|
const deadline = performance.now() + 10_000;
|
|
6002
6603
|
const direct = owner.binding;
|
|
@@ -6059,6 +6660,8 @@ export class FleetManager {
|
|
|
6059
6660
|
// Still closes the step editor below and tells the clicker: a throw must not leave the menu refreshing.
|
|
6060
6661
|
this.logger.warn({ err }, "Dashboard sign-in delivery failed");
|
|
6061
6662
|
}
|
|
6663
|
+
if (ok && action === "public")
|
|
6664
|
+
this.rememberPublicLinkOwner(owner);
|
|
6062
6665
|
// Only safe, static words enter General; never link, code or platform error text.
|
|
6063
6666
|
// #1742: Telegram's failure walks through opening the DM (never a bare /start, which starts a ClassicBot agent),
|
|
6064
6667
|
// with the "先開私訊 →" button when the bot's username is known; and for a while a bare /start there counts as
|
|
@@ -6083,6 +6686,7 @@ export class FleetManager {
|
|
|
6083
6686
|
revokeWebSessions() {
|
|
6084
6687
|
this.initializeWebSessions();
|
|
6085
6688
|
void this.publicWebLink?.close("dashboard revoke");
|
|
6689
|
+
this.forgetPublicLinkOwner();
|
|
6086
6690
|
this.webLoginCodes.revoke();
|
|
6087
6691
|
this.webGuestLoginCodes.revokeAll();
|
|
6088
6692
|
this.guestRevokes++; // and every guest /web still on its way
|
|
@@ -6633,11 +7237,17 @@ export class FleetManager {
|
|
|
6633
7237
|
* landed mid-edit) must not be cleared or disturbed (Prism b).
|
|
6634
7238
|
*/
|
|
6635
7239
|
async appendCleanupWarning(target, watch, warning, cleanupCommand) {
|
|
6636
|
-
|
|
6637
|
-
|
|
6638
|
-
|
|
6639
|
-
|
|
6640
|
-
|
|
7240
|
+
// #2175: one text per outcome, frozen at its first tick. The completion carries elapsed seconds, so a text made
|
|
7241
|
+
// anew each tick would never equal the one already handed over and landed (terminalLanded compares exactly).
|
|
7242
|
+
const key = JSON.stringify([warning, cleanupCommand ?? null]);
|
|
7243
|
+
if (watch.frozen?.key !== key) {
|
|
7244
|
+
watch.frozen = { key, text: formatRestartProgressCompletion("update", {
|
|
7245
|
+
...watch.summary,
|
|
7246
|
+
warningText: warning,
|
|
7247
|
+
warningCommand: cleanupCommand,
|
|
7248
|
+
}, watch.receipt.startedAt) };
|
|
7249
|
+
}
|
|
7250
|
+
const text = watch.frozen.text;
|
|
6641
7251
|
const stopWatching = () => {
|
|
6642
7252
|
this.updateCleanupWatch = null;
|
|
6643
7253
|
if (this.updateProgressTimer)
|
|
@@ -6645,10 +7255,22 @@ export class FleetManager {
|
|
|
6645
7255
|
this.updateProgressTimer = null;
|
|
6646
7256
|
};
|
|
6647
7257
|
if (text !== this.lastUpdateProgressText) {
|
|
6648
|
-
|
|
6649
|
-
|
|
6650
|
-
|
|
6651
|
-
|
|
7258
|
+
// #2175: while the completion is still owed by the background retry, that retry is the message's only
|
|
7259
|
+
// writer — this text (the completion plus the warning) goes to it rather than racing it. Handing it over is not
|
|
7260
|
+
// delivery: the receipt and this watch stay until the retry reports this exact text landed (a later tick).
|
|
7261
|
+
const progress = this.startupProgress;
|
|
7262
|
+
if (progress?.terminalLanded(text)) {
|
|
7263
|
+
// landed through the retry
|
|
7264
|
+
}
|
|
7265
|
+
else if (progress?.replaceTerminalText(text)) {
|
|
7266
|
+
return;
|
|
7267
|
+
}
|
|
7268
|
+
else {
|
|
7269
|
+
const adapter = this.adapters.get(target.adapterId);
|
|
7270
|
+
if (!adapter)
|
|
7271
|
+
return; // retry on a later tick
|
|
7272
|
+
await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
|
|
7273
|
+
}
|
|
6652
7274
|
this.lastUpdateProgressText = text;
|
|
6653
7275
|
}
|
|
6654
7276
|
const verdict = cleanupWatchVerdict(readUpdateProgress(this.dataDir), watch.receipt);
|
|
@@ -6666,7 +7288,7 @@ export class FleetManager {
|
|
|
6666
7288
|
if (this.updateProgressTimer)
|
|
6667
7289
|
clearInterval(this.updateProgressTimer);
|
|
6668
7290
|
const tick = async () => {
|
|
6669
|
-
if (this.updateProgressEditRunning)
|
|
7291
|
+
if (this.updateProgressEditRunning || this.shuttingDown)
|
|
6670
7292
|
return;
|
|
6671
7293
|
const marker = readUpdateProgress(this.dataDir);
|
|
6672
7294
|
if (!marker) {
|
|
@@ -6717,6 +7339,11 @@ export class FleetManager {
|
|
|
6717
7339
|
return;
|
|
6718
7340
|
}
|
|
6719
7341
|
}
|
|
7342
|
+
// #2476: from "starting" onward RestartProgress in the NEW process owns this
|
|
7343
|
+
// message (resume → N/M → ✅ finish). A late CLI-stage edit of "installed" is
|
|
7344
|
+
// what made "✅ 安裝完成" land after "啟動中 N/M". Failure still edits here.
|
|
7345
|
+
if (marker.progress.stage === "starting")
|
|
7346
|
+
return;
|
|
6720
7347
|
const target = marker.progress.target;
|
|
6721
7348
|
const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
|
|
6722
7349
|
if (!adapter)
|
|
@@ -6752,11 +7379,46 @@ export class FleetManager {
|
|
|
6752
7379
|
}
|
|
6753
7380
|
/** Start all instances from fleet config */
|
|
6754
7381
|
async startAll(configPath) {
|
|
7382
|
+
this.startupMemoryDeadline?.stop();
|
|
7383
|
+
const deadline = new StartupMemoryDeadline({
|
|
7384
|
+
current: () => this.startupMemoryDeadline === deadline && !this.shuttingDown,
|
|
7385
|
+
waiting: () => this.spawnGate.isStartupMemoryDeferred(),
|
|
7386
|
+
notify: sdNotify,
|
|
7387
|
+
log: message => this.logger.info(message),
|
|
7388
|
+
});
|
|
7389
|
+
this.startupMemoryDeadline = deadline;
|
|
7390
|
+
// A held first General must not postpone event-loop liveness monitoring.
|
|
7391
|
+
// No independent heartbeat: a genuinely frozen main loop still stops pinging.
|
|
7392
|
+
setSdNotifyWarn((fields, message) => this.logger.warn(fields, message));
|
|
7393
|
+
if (this.watchdogTimer)
|
|
7394
|
+
clearInterval(this.watchdogTimer);
|
|
7395
|
+
this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
|
|
7396
|
+
this.eventLoopWatch?.stop();
|
|
7397
|
+
this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
|
|
7398
|
+
try {
|
|
7399
|
+
await this.startAllWithinDeadline(configPath, deadline);
|
|
7400
|
+
}
|
|
7401
|
+
finally {
|
|
7402
|
+
deadline.stop();
|
|
7403
|
+
if (this.startupMemoryDeadline === deadline) {
|
|
7404
|
+
this.startupMemoryDeadline = null;
|
|
7405
|
+
if (!this.startupComplete) {
|
|
7406
|
+
if (this.watchdogTimer)
|
|
7407
|
+
clearInterval(this.watchdogTimer);
|
|
7408
|
+
this.watchdogTimer = null;
|
|
7409
|
+
this.eventLoopWatch?.stop();
|
|
7410
|
+
this.eventLoopWatch = null;
|
|
7411
|
+
}
|
|
7412
|
+
}
|
|
7413
|
+
}
|
|
7414
|
+
}
|
|
7415
|
+
async startAllWithinDeadline(configPath, deadline) {
|
|
6755
7416
|
this.loginWindow.reopen(); // a stopAll → startAll restart must accept login windows again
|
|
6756
7417
|
this.loginController?.reopen();
|
|
6757
7418
|
const startupStartedAt = Date.now();
|
|
6758
7419
|
FleetManager.signalTarget = this;
|
|
6759
7420
|
this.startupComplete = false;
|
|
7421
|
+
this.startupDashboard = null;
|
|
6760
7422
|
// Cleared here, not at the end of doStopAll: a stop has an async tail, and
|
|
6761
7423
|
// anything arriving during it is still part of the stop.
|
|
6762
7424
|
this.shuttingDown = false;
|
|
@@ -6770,6 +7432,12 @@ export class FleetManager {
|
|
|
6770
7432
|
envLease.release();
|
|
6771
7433
|
}
|
|
6772
7434
|
this.ensureDeliveryOutbox();
|
|
7435
|
+
// #1956: the spawn helper the fleet's tmux/stty commands are started by (its fork is cheap; the fleet's is not).
|
|
7436
|
+
// Started here and by the fleet's own timer — never by a tool handler — and stopped with the fleet.
|
|
7437
|
+
tmuxExecHelper.reopen();
|
|
7438
|
+
tmuxExecHelper.start();
|
|
7439
|
+
this.tmuxExecHelperTimer ??= setInterval(() => tmuxExecHelper.maintain(), 5_000);
|
|
7440
|
+
this.tmuxExecHelperTimer.unref?.();
|
|
6773
7441
|
this.rotateFleetLogs();
|
|
6774
7442
|
const fleet = this.loadConfig(configPath);
|
|
6775
7443
|
this.slimFleetConfigAtStartup();
|
|
@@ -6866,6 +7534,7 @@ export class FleetManager {
|
|
|
6866
7534
|
// #1565: the web chat as it was before this process — the configured instances' only (fleet.yaml and ClassicBot
|
|
6867
7535
|
// rooms), before the dashboard serves anything; from now on every change is written back (off the loop).
|
|
6868
7536
|
this.restoreWebChat(fleet);
|
|
7537
|
+
this.startSearchIndex(fleet);
|
|
6869
7538
|
// Poll classicBot.yaml for external changes every 30s
|
|
6870
7539
|
this.classicReloadTimer = setInterval(() => {
|
|
6871
7540
|
// Attributes the synchronous part, up to the reload's first await (#1235).
|
|
@@ -6915,6 +7584,17 @@ export class FleetManager {
|
|
|
6915
7584
|
return DailySummary.generateText(this.eventLog, instances, costMap, this.costGuard?.getFleetTotalCents() ?? 0);
|
|
6916
7585
|
});
|
|
6917
7586
|
this.dailySummary.start();
|
|
7587
|
+
// #2246 step 3: the task board's daily summary — its own time (defaults.task_board.summary_at, read at every look),
|
|
7588
|
+
// in the fleet's zone; a summary missed while the fleet was down goes out when it is back.
|
|
7589
|
+
this.taskBoardSummary = new TaskBoardSummaryClock({
|
|
7590
|
+
at: () => summaryTime(this.fleetConfig?.defaults?.task_board?.summary_at) ?? null,
|
|
7591
|
+
zone: () => this.usageZone(),
|
|
7592
|
+
now: () => new Date(),
|
|
7593
|
+
mark: summaryMarkFile(join(this.dataDir, "task-board-summary.json")),
|
|
7594
|
+
send: (since, until) => this.postTaskBoardSummary(since, until),
|
|
7595
|
+
failed: (err) => this.logger.warn({ err }, "The task board's daily summary failed; the next minute tries again"),
|
|
7596
|
+
});
|
|
7597
|
+
this.taskBoardSummary.start();
|
|
6918
7598
|
// Rotate classic channel chat logs daily (piggyback on daily summary timer)
|
|
6919
7599
|
this.classicChannels?.rotateLogs();
|
|
6920
7600
|
this.rotateInboxes();
|
|
@@ -6990,7 +7670,7 @@ export class FleetManager {
|
|
|
6990
7670
|
continue;
|
|
6991
7671
|
this.logger.warn({ adapter: need.adapterId, name }, "No general instance for adapter — auto-creating");
|
|
6992
7672
|
const generalDir = join(getAgendHome(), name);
|
|
6993
|
-
mkdirSync(generalDir, { recursive: true });
|
|
7673
|
+
mkdirSync(generalDir, { recursive: true, mode: 0o700 });
|
|
6994
7674
|
const backendName = fleet.defaults.backend ?? "claude-code";
|
|
6995
7675
|
this.ensureGeneralInstructions(generalDir, backendName, name);
|
|
6996
7676
|
fleet.instances[name] = {
|
|
@@ -7010,7 +7690,9 @@ export class FleetManager {
|
|
|
7010
7690
|
};
|
|
7011
7691
|
this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule, runId, retry) => this.handleScheduleTrigger(schedule, runId, retry), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name), (schedule, retry, reason) => this.scheduleRetryDropped(schedule, retry, reason), this.logger);
|
|
7012
7692
|
this.scheduler.init();
|
|
7693
|
+
this.scheduler.db.onTaskChange = (task) => this.searchIndex?.record(taskDoc(task));
|
|
7013
7694
|
this.logger.info("Scheduler initialized");
|
|
7695
|
+
this.attachTaskBoard(this.scheduler.db);
|
|
7014
7696
|
// Tips share the daily-report clock but remain an independent internal
|
|
7015
7697
|
// job: disabling daily_summary must not disable tips (and vice versa).
|
|
7016
7698
|
// The callback checks defaults.tips at fire time, so /tips on|off is hot.
|
|
@@ -7038,6 +7720,14 @@ export class FleetManager {
|
|
|
7038
7720
|
const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
|
|
7039
7721
|
const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
|
|
7040
7722
|
const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingProgressOperation === "full-restart" ? "reload" : pendingUpdateProgress ? "update" : "restart" });
|
|
7723
|
+
this.startupProgress = startupProgress;
|
|
7724
|
+
// Auth, restored chat, Classic roster and the task DB are ready. Listening is not service readiness:
|
|
7725
|
+
// the dashboard observes startup, while /health and READY retain their existing completion boundary.
|
|
7726
|
+
this.startupDashboard = new FleetStartup(this.configuredStartupInstanceNames(fleet, topicMode)
|
|
7727
|
+
.filter(name => !this.lifecycle.isPaused(name)));
|
|
7728
|
+
await this.startHealthServer(fleet.health_port ?? 19280);
|
|
7729
|
+
if (this.shuttingDown)
|
|
7730
|
+
return;
|
|
7041
7731
|
if (generals.length > 0) {
|
|
7042
7732
|
for (const [name, cfg] of generals) {
|
|
7043
7733
|
try {
|
|
@@ -7102,22 +7792,6 @@ export class FleetManager {
|
|
|
7102
7792
|
return startupProgress.start(this.restartProgressTarget());
|
|
7103
7793
|
});
|
|
7104
7794
|
}
|
|
7105
|
-
// The systemd watchdog answers exactly one question: is this process still
|
|
7106
|
-
// turning its event loop? Pinging from a timer proves that, and after the
|
|
7107
|
-
// blocking child-process calls were made async it is a meaningful signal —
|
|
7108
|
-
// a deadlocked or frozen fleet stops pinging and systemd restarts it.
|
|
7109
|
-
//
|
|
7110
|
-
// It deliberately does NOT gate on fleet health. "No adapter connected" or
|
|
7111
|
-
// "an instance crashed" must not kill the process: the fleet would be restarted
|
|
7112
|
-
// into the same broken state, and a user who has legitimately stopped every
|
|
7113
|
-
// instance would get a restart loop. Those conditions surface through /health
|
|
7114
|
-
// (which now returns 503) and through the General-topic notifications instead.
|
|
7115
|
-
setSdNotifyWarn((fields, message) => this.logger.warn(fields, message));
|
|
7116
|
-
this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
|
|
7117
|
-
// #1231: a stalled loop is what turns a slash command into "did not respond";
|
|
7118
|
-
// put each long stall in the log instead of leaving it to be guessed at.
|
|
7119
|
-
this.eventLoopWatch?.stop();
|
|
7120
|
-
this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
|
|
7121
7795
|
// EventLog.prune() existed but was never called, so `events` and `activity`
|
|
7122
7796
|
// grew without bound for the life of the install. Prune once at startup and
|
|
7123
7797
|
// daily after that; the timer is unref'd so it never holds the loop open.
|
|
@@ -7236,6 +7910,10 @@ export class FleetManager {
|
|
|
7236
7910
|
// operation so an older stop never reports. No pending operation means
|
|
7237
7911
|
// a plain boot — nothing was stopped by an operation, report nothing.
|
|
7238
7912
|
const backgroundWorkEndedNames = this.collectBackgroundWorkEndedNames(configuredNames, pendingUpdateProgress?.startedAt ?? null);
|
|
7913
|
+
// #2264: an update whose fleet comes back on another version did not land — say so in its final message (the
|
|
7914
|
+
// version it aimed for, the one running, and the one repair), never just "updated".
|
|
7915
|
+
const aimedAt = pendingProgressOperation === "update" ? pendingUpdateProgress?.progress.version : undefined;
|
|
7916
|
+
const notLanded = aimedAt && agendVersion !== "unknown" && aimedAt !== agendVersion ? t("update.not_landed", aimedAt, agendVersion) : null;
|
|
7239
7917
|
const completionSummary = {
|
|
7240
7918
|
running: started,
|
|
7241
7919
|
total,
|
|
@@ -7245,7 +7923,7 @@ export class FleetManager {
|
|
|
7245
7923
|
backgroundWorkEndedNames,
|
|
7246
7924
|
...(cleanupOutcome && "render" in cleanupOutcome
|
|
7247
7925
|
? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
|
|
7248
|
-
: {}),
|
|
7926
|
+
: notLanded ? { warningText: notLanded } : {}),
|
|
7249
7927
|
};
|
|
7250
7928
|
const progressCompleted = await startupProgress.finish(completionSummary);
|
|
7251
7929
|
// Armed only after the completion posted: an append racing ahead of it would be overwritten by it.
|
|
@@ -7289,9 +7967,9 @@ export class FleetManager {
|
|
|
7289
7967
|
}
|
|
7290
7968
|
// After "fleet ready", so the notice is not the first thing people see of a restart.
|
|
7291
7969
|
void this.announceWebChatOnce(agendVersion);
|
|
7970
|
+
// #named-link-ttl: a named link with auto_open comes back now — after the web listener and the chat connections.
|
|
7971
|
+
this.startPublicLinkAutoOpen();
|
|
7292
7972
|
}
|
|
7293
|
-
// Health HTTP endpoint
|
|
7294
|
-
this.startHealthServer(fleet.health_port ?? 19280);
|
|
7295
7973
|
// #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
|
|
7296
7974
|
// #1823: and polls: a deadline that passed meanwhile closes now (results once); an open one's countdown is redrawn.
|
|
7297
7975
|
void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
|
|
@@ -7302,17 +7980,32 @@ export class FleetManager {
|
|
|
7302
7980
|
}, 60 * 60 * 1000);
|
|
7303
7981
|
const onRestart = () => this.onGracefulRestartSignal(() => process.once("SIGUSR2", onRestart));
|
|
7304
7982
|
process.once("SIGUSR2", onRestart);
|
|
7305
|
-
// SIGUSR1: full process reload (graceful stop → exit
|
|
7983
|
+
// SIGUSR1: full process reload (graceful stop → exit). Nothing in this AgEnD sends it any more (#2356); an older
|
|
7984
|
+
// CLI or a hand-sent signal still may, and the exit code then lets the owning manager start the next fleet.
|
|
7306
7985
|
process.once("SIGUSR1", () => this.onFullRestartSignal());
|
|
7307
7986
|
// A SIGHUP may arrive after the PID/general is available but before the
|
|
7308
7987
|
// rest of startup finishes. Replay one coalesced reload only after all
|
|
7309
7988
|
// startup-owned lifecycle work and signal handlers are in place.
|
|
7989
|
+
if (this.shuttingDown || this.startupMemoryDeadline !== deadline)
|
|
7990
|
+
return;
|
|
7310
7991
|
this.finishStartup(bootUpdateMarker);
|
|
7992
|
+
this.startupDashboard = null;
|
|
7993
|
+
this.publishStartupStatus();
|
|
7311
7994
|
// Tell systemd we are ready only now. This used to fire right after the
|
|
7312
7995
|
// generals started — before adapters, classic instances, topic creation and the
|
|
7313
7996
|
// health server — so `systemctl start` returned success while the fleet was
|
|
7314
7997
|
// still deaf: no path existed for a user message to arrive.
|
|
7315
7998
|
sdNotify("READY=1");
|
|
7999
|
+
// #2481: this version runs — the CLI's start-failure banner stops, and this is the version to go back to later.
|
|
8000
|
+
try {
|
|
8001
|
+
const { ownPackage, recordStarted } = await import("./start-failure.js");
|
|
8002
|
+
const version = ownPackage().version;
|
|
8003
|
+
if (version)
|
|
8004
|
+
recordStarted(this.dataDir, version, process.pid, Date.now());
|
|
8005
|
+
}
|
|
8006
|
+
catch (err) {
|
|
8007
|
+
this.logger.debug({ err }, "fleet-started record not written");
|
|
8008
|
+
}
|
|
7316
8009
|
const health = this.getFleetHealth();
|
|
7317
8010
|
if (health.status !== "ok") {
|
|
7318
8011
|
this.logger.warn({ health }, "Fleet started with problems — see /health");
|
|
@@ -7465,48 +8158,109 @@ export class FleetManager {
|
|
|
7465
8158
|
const err = results[i].reason;
|
|
7466
8159
|
this.logger.error({ adapterId, err: err?.message ?? err }, "Adapter startup failed — scheduling background retry");
|
|
7467
8160
|
this.adapterState.set(adapterId, { status: "retrying", retryCount: 0, lastError: err?.message ?? String(err) });
|
|
7468
|
-
this.scheduleAdapterRetry(adapterId
|
|
8161
|
+
this.scheduleAdapterRetry(adapterId);
|
|
7469
8162
|
// Notify admin via whichever adapter is already up
|
|
7470
8163
|
this.notifyAdapterFailure(adapterId, err?.message ?? String(err));
|
|
7471
8164
|
}
|
|
7472
8165
|
}
|
|
7473
8166
|
}
|
|
7474
|
-
/**
|
|
7475
|
-
scheduleAdapterRetry(adapterId
|
|
7476
|
-
const MAX_RETRIES = 10;
|
|
8167
|
+
/** Keep trying while the fleet runs, with a five-minute cap even after a long outage. */
|
|
8168
|
+
scheduleAdapterRetry(adapterId) {
|
|
7477
8169
|
const INITIAL_DELAY_MS = 5_000;
|
|
7478
8170
|
const MAX_DELAY_MS = 5 * 60_000;
|
|
7479
8171
|
const state = this.adapterState.get(adapterId);
|
|
7480
|
-
if (!state || state.
|
|
7481
|
-
if (state) {
|
|
7482
|
-
state.status = "failed";
|
|
7483
|
-
this.logger.error({ adapterId, retries: state.retryCount }, "Adapter retry exhausted — giving up");
|
|
7484
|
-
this.notifyAdapterFailure(adapterId, `Retry exhausted after ${state.retryCount} attempts. Check token/network and restart fleet.`);
|
|
7485
|
-
}
|
|
8172
|
+
if (this.shuttingDown || !state || state.retryTimer || this.adapterRetryInFlight.has(adapterId))
|
|
7486
8173
|
return;
|
|
7487
|
-
|
|
7488
|
-
const delay = Math.min(INITIAL_DELAY_MS * Math.pow(2, state.retryCount), MAX_DELAY_MS);
|
|
8174
|
+
const delay = Math.min(INITIAL_DELAY_MS * Math.pow(2, Math.min(state.retryCount, 6)), MAX_DELAY_MS);
|
|
7489
8175
|
this.logger.info({ adapterId, attempt: state.retryCount + 1, delay_ms: delay }, "Scheduling adapter retry");
|
|
7490
|
-
|
|
8176
|
+
const timer = setTimeout(() => {
|
|
8177
|
+
const current = this.adapterState.get(adapterId);
|
|
8178
|
+
if (this.shuttingDown || current?.retryTimer !== timer)
|
|
8179
|
+
return;
|
|
8180
|
+
current.retryTimer = undefined;
|
|
8181
|
+
void this.attemptAdapterRetry(adapterId);
|
|
8182
|
+
}, delay);
|
|
8183
|
+
state.retryTimer = timer;
|
|
8184
|
+
timer.unref?.();
|
|
8185
|
+
}
|
|
8186
|
+
/** One login per connection, shared by the background timer and the operator's Retry now. */
|
|
8187
|
+
attemptAdapterRetry(adapterId) {
|
|
8188
|
+
const pending = this.adapterRetryInFlight.get(adapterId);
|
|
8189
|
+
if (pending)
|
|
8190
|
+
return pending;
|
|
8191
|
+
if (this.shuttingDown || !this.secureConnectionChannel(adapterId))
|
|
8192
|
+
return Promise.resolve();
|
|
8193
|
+
const attempt = Promise.resolve().then(async () => {
|
|
8194
|
+
const channel = this.secureConnectionChannel(adapterId);
|
|
8195
|
+
if (this.shuttingDown || !channel)
|
|
8196
|
+
return;
|
|
8197
|
+
const generation = this.settingsGeneration;
|
|
8198
|
+
const binding = JSON.stringify([channel, this.getPrimaryAdapterId()]);
|
|
8199
|
+
const token = process.env[channel.bot_token_env];
|
|
8200
|
+
const operation = { owner: Symbol("adapter-retry"), assert: () => {
|
|
8201
|
+
if (this.shuttingDown || generation !== this.settingsGeneration
|
|
8202
|
+
|| JSON.stringify([this.secureConnectionChannel(adapterId), this.getPrimaryAdapterId()]) !== binding
|
|
8203
|
+
|| process.env[channel.bot_token_env] !== token)
|
|
8204
|
+
throw new Error("adapter retry superseded");
|
|
8205
|
+
} };
|
|
8206
|
+
const state = this.adapterState.get(adapterId) ?? { status: "retrying", retryCount: 0 };
|
|
8207
|
+
state.status = "retrying";
|
|
7491
8208
|
state.retryCount++;
|
|
8209
|
+
this.adapterState.set(adapterId, state);
|
|
7492
8210
|
try {
|
|
7493
|
-
if (
|
|
7494
|
-
await this.startSingleAdapter(
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7498
|
-
|
|
7499
|
-
|
|
7500
|
-
|
|
8211
|
+
if (this.getPrimaryAdapterId() === adapterId)
|
|
8212
|
+
await this.startSingleAdapter(this.fleetConfig, channel, undefined, operation);
|
|
8213
|
+
else
|
|
8214
|
+
await this.startAdditionalAdapter(channel, true, undefined, operation);
|
|
8215
|
+
if (this.shuttingDown || generation !== this.settingsGeneration)
|
|
8216
|
+
return;
|
|
8217
|
+
if (!this.adapters.has(adapterId))
|
|
8218
|
+
throw new Error("Adapter token is not configured");
|
|
8219
|
+
// Health events can replace the state object during login. Update its current owner.
|
|
8220
|
+
const current = this.adapterState.get(adapterId);
|
|
8221
|
+
current.status = "connected";
|
|
8222
|
+
current.lastError = undefined;
|
|
7501
8223
|
this.logger.info({ adapterId, attempts: state.retryCount }, "Adapter reconnected on retry");
|
|
7502
8224
|
this.notifyAdapterRecovery(adapterId, state.retryCount);
|
|
7503
8225
|
}
|
|
7504
8226
|
catch (err) {
|
|
7505
|
-
|
|
7506
|
-
|
|
7507
|
-
this.
|
|
8227
|
+
if (this.shuttingDown || generation !== this.settingsGeneration)
|
|
8228
|
+
return;
|
|
8229
|
+
const current = this.adapterState.get(adapterId);
|
|
8230
|
+
current.status = "retrying";
|
|
8231
|
+
current.lastError = err?.message ?? String(err);
|
|
8232
|
+
this.logger.warn({ adapterId, attempt: state.retryCount, err: current.lastError }, "Adapter retry failed");
|
|
7508
8233
|
}
|
|
7509
|
-
}
|
|
8234
|
+
}).finally(() => {
|
|
8235
|
+
if (this.adapterRetryInFlight.get(adapterId) !== attempt)
|
|
8236
|
+
return;
|
|
8237
|
+
this.adapterRetryInFlight.delete(adapterId);
|
|
8238
|
+
const channel = this.secureConnectionChannel(adapterId);
|
|
8239
|
+
if (!this.shuttingDown && channel && this.adapterState.get(adapterId)?.status !== "connected") {
|
|
8240
|
+
this.scheduleAdapterRetry(adapterId);
|
|
8241
|
+
}
|
|
8242
|
+
});
|
|
8243
|
+
this.adapterRetryInFlight.set(adapterId, attempt);
|
|
8244
|
+
return attempt;
|
|
8245
|
+
}
|
|
8246
|
+
retryAdapterNow(adapterId) {
|
|
8247
|
+
if (this.shuttingDown)
|
|
8248
|
+
throw new Error("The fleet is stopping");
|
|
8249
|
+
if (!this.secureConnectionChannel(adapterId))
|
|
8250
|
+
throw new Error("Connection not found");
|
|
8251
|
+
if (this.adapterRetryInFlight.has(adapterId) || this.adapterRestarting.has(adapterId))
|
|
8252
|
+
return { queued: true, already_running: true };
|
|
8253
|
+
const state = this.adapterState.get(adapterId);
|
|
8254
|
+
if (state?.retryTimer) {
|
|
8255
|
+
clearTimeout(state.retryTimer);
|
|
8256
|
+
state.retryTimer = undefined;
|
|
8257
|
+
}
|
|
8258
|
+
const adapter = this.adapters.get(adapterId);
|
|
8259
|
+
if (adapter && this.connectionStatus(adapterId, state, this.worlds.get(adapterId)).status === "connected")
|
|
8260
|
+
return { queued: false };
|
|
8261
|
+
const run = adapter ? this.restartAdapter(adapter, adapterId) : this.attemptAdapterRetry(adapterId);
|
|
8262
|
+
void run.catch(err => this.logger.warn({ adapterId, err }, "Requested adapter retry failed"));
|
|
8263
|
+
return { queued: true };
|
|
7510
8264
|
}
|
|
7511
8265
|
/** Notify admin about adapter failure (uses any available adapter). */
|
|
7512
8266
|
notifyAdapterFailure(adapterId, error) {
|
|
@@ -7561,6 +8315,7 @@ export class FleetManager {
|
|
|
7561
8315
|
: snapshot.status === "stopped" ? "failed"
|
|
7562
8316
|
: "retrying";
|
|
7563
8317
|
this.adapterState.set(adapterId, {
|
|
8318
|
+
...previous,
|
|
7564
8319
|
status,
|
|
7565
8320
|
retryCount: previous?.retryCount ?? 0,
|
|
7566
8321
|
lastError: status === "connected" ? undefined : snapshot.lastReconnectReason ?? previous?.lastError,
|
|
@@ -7669,12 +8424,12 @@ export class FleetManager {
|
|
|
7669
8424
|
return;
|
|
7670
8425
|
}
|
|
7671
8426
|
const accessDir = join(this.dataDir, "access");
|
|
7672
|
-
mkdirSync(accessDir, { recursive: true });
|
|
8427
|
+
mkdirSync(accessDir, { recursive: true, mode: 0o700 });
|
|
7673
8428
|
const accessStatePath = join(accessDir, "access.json");
|
|
7674
8429
|
const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
|
|
7675
8430
|
this.warnIfAccessModeOverridden(accessManager, accessStatePath);
|
|
7676
8431
|
const inboxDir = join(this.dataDir, "inbox");
|
|
7677
|
-
mkdirSync(inboxDir, { recursive: true });
|
|
8432
|
+
mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
|
|
7678
8433
|
const adapterId = channelConfig.id ?? channelConfig.type;
|
|
7679
8434
|
const adapter = await createAdapter(channelConfig, {
|
|
7680
8435
|
id: adapterId,
|
|
@@ -7820,12 +8575,12 @@ export class FleetManager {
|
|
|
7820
8575
|
return;
|
|
7821
8576
|
}
|
|
7822
8577
|
const accessDir = join(this.dataDir, "access");
|
|
7823
|
-
mkdirSync(accessDir, { recursive: true });
|
|
8578
|
+
mkdirSync(accessDir, { recursive: true, mode: 0o700 });
|
|
7824
8579
|
const accessStatePath = join(accessDir, `access-${adapterId}.json`);
|
|
7825
8580
|
const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
|
|
7826
8581
|
this.warnIfAccessModeOverridden(accessManager, accessStatePath);
|
|
7827
8582
|
const inboxDir = join(this.dataDir, "inbox");
|
|
7828
|
-
mkdirSync(inboxDir, { recursive: true });
|
|
8583
|
+
mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
|
|
7829
8584
|
const adapter = await createAdapter(channelConfig, {
|
|
7830
8585
|
id: adapterId,
|
|
7831
8586
|
botToken,
|
|
@@ -8131,7 +8886,7 @@ export class FleetManager {
|
|
|
8131
8886
|
// means we cannot verify the pane, which is treated as dead (the same
|
|
8132
8887
|
// conclusion the old code reached only by throwing).
|
|
8133
8888
|
try {
|
|
8134
|
-
const { execFile } = await import("
|
|
8889
|
+
const { execFile } = await import("./child-process.js");
|
|
8135
8890
|
const { promisify } = await import("node:util");
|
|
8136
8891
|
const { getTmuxSocketName } = await import("./paths.js");
|
|
8137
8892
|
// Honour socket isolation: without -L this queried the user's default
|
|
@@ -8681,6 +9436,22 @@ export class FleetManager {
|
|
|
8681
9436
|
this.logger.debug({ adapterId: msg.adapterId, threadId: threadId ?? null, messageId: msg.messageId, reason: drop }, "Inbound message dropped before the dedup claim — key not consumed");
|
|
8682
9437
|
return;
|
|
8683
9438
|
}
|
|
9439
|
+
// #2246 step 3 (#2341 review r2): a reply to a Needs you Send back / Answer prompt is the card's reason or answer, not
|
|
9440
|
+
// input for an instance — judged by the PROMPT's world (its admins, its instance, its room as it is now; the hub's
|
|
9441
|
+
// handleReply), before the room's generic access below: in a shared group the room may be another bot's, whose
|
|
9442
|
+
// policy has no say over that world's prompt. Only that exact reply, by the admin who clicked, while the prompt
|
|
9443
|
+
// stands. Taken once: another bot's copy of a taken reply is dropped here. All else flows on.
|
|
9444
|
+
if (!msg.isBotMessage && msg.replyTo && this.needsYou) {
|
|
9445
|
+
if (this.needsYou.wasTakenReply(msg.chatId, msg.messageId)) {
|
|
9446
|
+
this.logger.debug({ adapterId: msg.adapterId, messageId: msg.messageId }, "Another bot's copy of a Needs you answer already taken — dropped");
|
|
9447
|
+
return;
|
|
9448
|
+
}
|
|
9449
|
+
if (this.needsYou.handleReply({
|
|
9450
|
+
adapterId: msg.adapterId, chatId: msg.chatId, ...(threadId !== undefined ? { threadId } : {}), replyTo: msg.replyTo,
|
|
9451
|
+
messageId: msg.messageId, userId: msg.userId, ...(msg.username ? { username: msg.username } : {}), text: msg.text ?? "",
|
|
9452
|
+
}))
|
|
9453
|
+
return;
|
|
9454
|
+
}
|
|
8684
9455
|
// Access control — classic channels are open to all, others require an allowed
|
|
8685
9456
|
// user. This runs before the dedup claim, and is judged by the adapter that
|
|
8686
9457
|
// owns the topic rather than the one that happened to receive the message:
|
|
@@ -8791,6 +9562,10 @@ export class FleetManager {
|
|
|
8791
9562
|
// #1920 step 4b-3: /web typed in a ClassicBot room (a group: `/web@ThisBot`) by someone who is not a fleet admin,
|
|
8792
9563
|
// while guest sign-in is on: their guest code, privately — as anywhere else. An admin's goes on as before.
|
|
8793
9564
|
if (/^\/(?:web|dashboard)(?:\s|$)/i.test(text) && msgAdapter && this.isGuestWebAsk(msg.adapterId, msg.userId)) {
|
|
9565
|
+
// #2359: this door must not skip the deprecation notice TopicCommands shows on /dashboard.
|
|
9566
|
+
if (/^\/dashboard(?:\s|$)/i.test(text)) {
|
|
9567
|
+
await msgAdapter.sendText(chatId, t("dashboard.deprecated")).catch(() => { });
|
|
9568
|
+
}
|
|
8794
9569
|
await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username,
|
|
8795
9570
|
arg: text.replace(/^\/(?:web|dashboard)/i, "").trim().toLowerCase(), replyHere: body => msgAdapter.sendText(chatId, body) });
|
|
8796
9571
|
return;
|
|
@@ -9116,7 +9891,7 @@ export class FleetManager {
|
|
|
9116
9891
|
...generalReactions.meta,
|
|
9117
9892
|
...extraMeta,
|
|
9118
9893
|
},
|
|
9119
|
-
}, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
|
|
9894
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? msg.text, this.startRetryHoldFor(generalInstance, msg));
|
|
9120
9895
|
generalReactions.consume();
|
|
9121
9896
|
this.afterUserInboundDelivered(generalInstance, msg);
|
|
9122
9897
|
this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
|
|
@@ -9212,7 +9987,7 @@ export class FleetManager {
|
|
|
9212
9987
|
...reactions.meta,
|
|
9213
9988
|
...extraMeta,
|
|
9214
9989
|
},
|
|
9215
|
-
}, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
|
|
9990
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? msg.text, this.startRetryHoldFor(instanceName, msg));
|
|
9216
9991
|
// Only after the message actually went out. A failed delivery keeps the
|
|
9217
9992
|
// reactions queued for the retry / the next message.
|
|
9218
9993
|
reactions.consume();
|
|
@@ -9237,9 +10012,14 @@ export class FleetManager {
|
|
|
9237
10012
|
* history (#1565). #1920 §5.2: said in the instance's topic, General or room, so it is that channel's, by that account.
|
|
9238
10013
|
*/
|
|
9239
10014
|
emitPlatformInbound(instance, msg, text) {
|
|
10015
|
+
// #2281: where it is on the platform — a web reply to it is then a native reply there. The platform thread it came
|
|
10016
|
+
// from (#1085: a ClassicBot's `threadId` may be its registry key).
|
|
10017
|
+
const thread = msg.transportThreadId ?? msg.threadId;
|
|
10018
|
+
const posts = msg.adapterId && msg.chatId && msg.messageId
|
|
10019
|
+
? [{ adapterId: msg.adapterId, chatId: msg.chatId, ...(thread && thread !== msg.chatId ? { threadId: thread } : {}), messageId: msg.messageId }] : undefined;
|
|
9240
10020
|
this.emitSseEvent("message", {
|
|
9241
10021
|
instance, sender: msg.username, role: "user", text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
9242
|
-
channel: true, from: webChatFrom(msg.adapterId, msg.userId),
|
|
10022
|
+
channel: true, from: webChatFrom(msg.adapterId, msg.userId), ...(posts ? { posts } : {}),
|
|
9243
10023
|
});
|
|
9244
10024
|
}
|
|
9245
10025
|
/** Handle outbound tool calls from a daemon instance */
|
|
@@ -9341,12 +10121,21 @@ export class FleetManager {
|
|
|
9341
10121
|
}
|
|
9342
10122
|
}
|
|
9343
10123
|
checkToolPermission(sink, instanceName, tool) {
|
|
10124
|
+
if (isCanvasWorkerTool(tool) && !this.canvasWorkerEnabled())
|
|
10125
|
+
return { allowed: false, message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE };
|
|
9344
10126
|
const profile = resolveToolSet(this.fleetConfig?.instances[instanceName], instanceName);
|
|
9345
10127
|
const allowed = mayUseTool(profile, tool);
|
|
9346
10128
|
if (!allowed) {
|
|
9347
10129
|
this.logger.warn({ sink, instance: instanceName, profile, tool, enforced: true }, "tool-permissions: refused");
|
|
9348
10130
|
return { allowed: false, message: toolRefusedMessage(profile, tool) };
|
|
9349
10131
|
}
|
|
10132
|
+
// The person's dashboard switches (dashboard-permissions.ts), on top of the profile, as configured now.
|
|
10133
|
+
// Its own switches, else the defaults' — a ClassicBot identity has no instances entry and runs on the defaults (#2391 review).
|
|
10134
|
+
const switchedOff = dashboardRefusalIn(this.fleetConfig, instanceName, tool);
|
|
10135
|
+
if (switchedOff) {
|
|
10136
|
+
this.logger.warn({ sink, instance: instanceName, profile, tool, enforced: true, dashboard: true }, "tool-permissions: refused");
|
|
10137
|
+
return { allowed: false, message: switchedOff };
|
|
10138
|
+
}
|
|
9350
10139
|
this.logger.debug({ sink, instance: instanceName, profile, tool, enforced: true }, "tool-permissions: allowed");
|
|
9351
10140
|
return { allowed: true, message: "" };
|
|
9352
10141
|
}
|
|
@@ -9354,6 +10143,9 @@ export class FleetManager {
|
|
|
9354
10143
|
this.touchActivity(instanceName);
|
|
9355
10144
|
this.setTopicIcon(instanceName, "green");
|
|
9356
10145
|
const tool = msg.tool;
|
|
10146
|
+
// #1774: who sends this — taken before anything here awaits (sticker checks, the platform send): a reply that waits
|
|
10147
|
+
// must never be read as a same-name replacement's (removed and re-registered meanwhile).
|
|
10148
|
+
const sentBy = tool === "reply" ? this.webChatIdentity(instanceName) : undefined;
|
|
9357
10149
|
const args = (msg.args ?? {});
|
|
9358
10150
|
const requestId = msg.requestId;
|
|
9359
10151
|
const fleetRequestId = msg.fleetRequestId;
|
|
@@ -9386,6 +10178,27 @@ export class FleetManager {
|
|
|
9386
10178
|
respond(null, permitted.message);
|
|
9387
10179
|
return;
|
|
9388
10180
|
}
|
|
10181
|
+
// Fleet tools resolve their own targets. A missing chat world must not prevent local
|
|
10182
|
+
// delivery, inspection or receipt lookup; only channel tools need reply routing below.
|
|
10183
|
+
const handler = outboundHandlers.get(tool);
|
|
10184
|
+
if (handler && !usesChannelAddress(tool)) {
|
|
10185
|
+
this.eventLog?.logActivity("tool_call", senderSessionName ?? instanceName, this.summarizeToolCall(tool, args));
|
|
10186
|
+
await handler(this, args, respond, {
|
|
10187
|
+
instanceName, requestId, fleetRequestId, senderSessionName, operationId, sourceDaemonBootId,
|
|
10188
|
+
});
|
|
10189
|
+
return;
|
|
10190
|
+
}
|
|
10191
|
+
// #2305: a reaction to a web chat message is the web chat's own (any fleet, web-only included): never an adapter's.
|
|
10192
|
+
if (tool === "react" && typeof args.message_id === "string" && isWebMessageId(args.message_id)) {
|
|
10193
|
+
// The socket's own instance, never a session name the caller wrote itself (as checkToolPermission: it was not
|
|
10194
|
+
// identified) — a channel tool's call from a daemon carries none anyway (#2310 review).
|
|
10195
|
+
const r = await this.reactToWebMessage(instanceName, args.message_id, args.emoji);
|
|
10196
|
+
if (r.ok)
|
|
10197
|
+
respond("ok");
|
|
10198
|
+
else
|
|
10199
|
+
respond(null, r.error);
|
|
10200
|
+
return;
|
|
10201
|
+
}
|
|
9389
10202
|
// A fleet with no chat platform at all is driven from the web dashboard alone: a reply has nowhere
|
|
9390
10203
|
// else to go, and "retry shortly" would have the agent retry forever. It goes to the web chat.
|
|
9391
10204
|
const webOnlyReply = tool === "reply" && this.worlds.size === 0 && this.isWebOnlyFleet();
|
|
@@ -9512,6 +10325,7 @@ export class FleetManager {
|
|
|
9512
10325
|
args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
|
|
9513
10326
|
}
|
|
9514
10327
|
}
|
|
10328
|
+
const replyIdentity = sentBy; // #1774: taken at the handler's entry
|
|
9515
10329
|
const original = respond;
|
|
9516
10330
|
const respondAndRecord = (result, error) => {
|
|
9517
10331
|
let buttonsView = null;
|
|
@@ -9543,12 +10357,13 @@ export class FleetManager {
|
|
|
9543
10357
|
try {
|
|
9544
10358
|
// #1920 §5.2: shown to the instance's guests only when it landed in the instance's own topic or room.
|
|
9545
10359
|
let inChannel = false;
|
|
10360
|
+
const replyAdapterId = contextAdapterId ?? this.getInstanceAdapterId(instanceName);
|
|
9546
10361
|
try {
|
|
9547
10362
|
inChannel = outAdapter !== WEB_ONLY_REPLY_SINK
|
|
9548
|
-
&& this.landedInOwnChannel(instanceName,
|
|
10363
|
+
&& this.landedInOwnChannel(instanceName, replyAdapterId, result);
|
|
9549
10364
|
}
|
|
9550
10365
|
catch { /* not known: not the channel's (fail closed), and the bookkeeping below still runs */ }
|
|
9551
|
-
void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel);
|
|
10366
|
+
void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel, replyAdapterId ? { adapterId: replyAdapterId, result } : undefined, replyIdentity);
|
|
9552
10367
|
}
|
|
9553
10368
|
catch (err) {
|
|
9554
10369
|
this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
|
|
@@ -9577,19 +10392,14 @@ export class FleetManager {
|
|
|
9577
10392
|
if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
|
|
9578
10393
|
return;
|
|
9579
10394
|
}
|
|
9580
|
-
// Log tool calls for activity visualization
|
|
9581
|
-
const senderLabel = senderSessionName ?? instanceName;
|
|
9582
|
-
this.eventLog?.logActivity("tool_call", senderLabel, this.summarizeToolCall(tool, args));
|
|
9583
|
-
// Dispatch fleet-specific tools via handler map
|
|
9584
|
-
const handler = outboundHandlers.get(tool);
|
|
9585
10395
|
if (handler) {
|
|
10396
|
+
this.eventLog?.logActivity("tool_call", senderSessionName ?? instanceName, this.summarizeToolCall(tool, args));
|
|
9586
10397
|
await handler(this, args, respond, {
|
|
9587
10398
|
instanceName, requestId, fleetRequestId, senderSessionName, operationId, sourceDaemonBootId,
|
|
9588
10399
|
});
|
|
9589
10400
|
}
|
|
9590
|
-
else
|
|
10401
|
+
else
|
|
9591
10402
|
respond(null, `Unknown tool: ${tool}`);
|
|
9592
|
-
}
|
|
9593
10403
|
}
|
|
9594
10404
|
/** Side effects of a routed reply: cancel-button lifecycle, logs, SSE, chat log. */
|
|
9595
10405
|
/** No chat platform is configured at all: the web dashboard is the fleet's only surface. */
|
|
@@ -9630,7 +10440,12 @@ export class FleetManager {
|
|
|
9630
10440
|
return false;
|
|
9631
10441
|
}
|
|
9632
10442
|
}
|
|
9633
|
-
afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false) {
|
|
10443
|
+
afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false, sentAs, identity = this.webChatIdentity(instanceName)) {
|
|
10444
|
+
// #2281: where the reply is, when it landed in the instance's own place — a web reply to it is then a native reply
|
|
10445
|
+
// there. A reply elsewhere (another chat, the web-only sink) records none.
|
|
10446
|
+
const sent = (sentAs?.result && typeof sentAs.result === "object" ? sentAs.result : {});
|
|
10447
|
+
const post = inChannel && sentAs && typeof sent.messageId === "string" && typeof sent.chatId === "string"
|
|
10448
|
+
? { adapterId: sentAs.adapterId, chatId: sent.chatId, ...(typeof sent.threadId === "string" && sent.threadId ? { threadId: sent.threadId } : {}), messageId: sent.messageId } : undefined;
|
|
9634
10449
|
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
9635
10450
|
// more work either. Split the difference: an instance that is clearly
|
|
9636
10451
|
// idle loses the button now; one that looks busy keeps it (re-posted
|
|
@@ -9657,7 +10472,16 @@ export class FleetManager {
|
|
|
9657
10472
|
// copies are done, and an instance's replies wait for the one before them: they reach the chat in order.
|
|
9658
10473
|
const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
|
|
9659
10474
|
const ts = new Date().toISOString();
|
|
9660
|
-
|
|
10475
|
+
// #1774: the instance this reply is for — removed while its files were being copied (a same-name replacement
|
|
10476
|
+
// registered meanwhile included), the reply is no longer its to show: no chat made again, none of the new one's
|
|
10477
|
+
// touched, and the copies made for it go. A replacement's own replies are its own.
|
|
10478
|
+
// Its identity was taken when the reply was sent (identity), never re-read after the platform answered.
|
|
10479
|
+
const current = () => this.webChatIdentityCurrent(instanceName, identity);
|
|
10480
|
+
const show = (attachments, reserved) => {
|
|
10481
|
+
if (!current()) {
|
|
10482
|
+
this.webFiles.dropServed(attachments.map(a => a.id));
|
|
10483
|
+
return;
|
|
10484
|
+
}
|
|
9661
10485
|
// The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
|
|
9662
10486
|
// role that may get HTML preview cards. Never inferred from the sender name or the text.
|
|
9663
10487
|
this.emitSseEvent("message", {
|
|
@@ -9667,18 +10491,25 @@ export class FleetManager {
|
|
|
9667
10491
|
...(attachments.length ? { attachments } : {}),
|
|
9668
10492
|
...(buttons ? { buttons } : {}), // #1266
|
|
9669
10493
|
...(inChannel ? { channel: true } : {}), // #1920 §5.2
|
|
10494
|
+
...(post ? { posts: [post] } : {}), // #2281
|
|
10495
|
+
...(reserved !== undefined ? { reserved } : {}), // #1774
|
|
9670
10496
|
});
|
|
9671
10497
|
};
|
|
9672
10498
|
const before = this.webReplyOrder.get(instanceName);
|
|
9673
|
-
|
|
10499
|
+
// #1774: a removal of this instance still going decides whether a reply sent before it began is shown — it waits.
|
|
10500
|
+
const removal = this.webChatRemovalPending(instanceName, identity);
|
|
10501
|
+
if (!replyFiles.length && !before && !removal) {
|
|
9674
10502
|
show([]);
|
|
9675
10503
|
return Promise.resolve();
|
|
9676
10504
|
}
|
|
10505
|
+
// #1774: its place in the chat is now — a person's message sent while the files are copied comes after it.
|
|
10506
|
+
const reserved = this.webChatHistory.reserve();
|
|
9677
10507
|
const done = (before ?? Promise.resolve())
|
|
9678
|
-
.then(() => this.webReplyAttachments(instanceName, replyFiles))
|
|
10508
|
+
.then(() => this.webReplyAttachments(instanceName, replyFiles, current))
|
|
10509
|
+
.then(async (attachments) => { await this.webChatRemovalPending(instanceName, identity); return attachments; })
|
|
9679
10510
|
.catch((err) => { this.logger.warn({ err, instanceName }, "Reply files could not be prepared for the web chat"); return []; })
|
|
9680
10511
|
.then((attachments) => { try {
|
|
9681
|
-
show(attachments);
|
|
10512
|
+
show(attachments, reserved);
|
|
9682
10513
|
}
|
|
9683
10514
|
catch (err) {
|
|
9684
10515
|
this.logger.warn({ err, instanceName }, "Reply could not be shown in the web chat");
|
|
@@ -9693,11 +10524,13 @@ export class FleetManager {
|
|
|
9693
10524
|
* the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
|
|
9694
10525
|
* one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
|
|
9695
10526
|
*/
|
|
9696
|
-
async webReplyAttachments(instanceName, paths) {
|
|
10527
|
+
async webReplyAttachments(instanceName, paths, current = () => true) {
|
|
9697
10528
|
const home = getAgendHome();
|
|
9698
10529
|
const dir = webFilesDir(home, instanceName);
|
|
9699
10530
|
const out = [];
|
|
9700
10531
|
for (const path of paths) {
|
|
10532
|
+
if (!current())
|
|
10533
|
+
break; // #1774: removed meanwhile — copy no more for it
|
|
9701
10534
|
const id = randomBytes(16).toString("hex");
|
|
9702
10535
|
const got = dir ? await snapshotReplyFile(path, dir, id, { stateDir: home, maxDirBytes: this.webReplyMaxBytes }) : null;
|
|
9703
10536
|
if (got && "evicted" in got && got.evicted.length)
|
|
@@ -10009,13 +10842,14 @@ export class FleetManager {
|
|
|
10009
10842
|
if (!ipc || !fleetRequestId)
|
|
10010
10843
|
return;
|
|
10011
10844
|
const type = String(msg.type);
|
|
10012
|
-
const responseType = type.
|
|
10013
|
-
: type.startsWith("
|
|
10014
|
-
: type
|
|
10015
|
-
: type === "
|
|
10016
|
-
: type === "
|
|
10017
|
-
|
|
10018
|
-
|
|
10845
|
+
const responseType = isCanvasWorkerTool(type.replace(/^fleet_/, "")) ? "fleet_web_theme_response"
|
|
10846
|
+
: type.startsWith("fleet_schedule_") ? "fleet_schedule_response"
|
|
10847
|
+
: type.startsWith("fleet_decision_") ? "fleet_decision_response"
|
|
10848
|
+
: type === "fleet_task" ? "fleet_task_response"
|
|
10849
|
+
: type === "fleet_set_display_name" ? "fleet_display_name_response"
|
|
10850
|
+
: type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis"
|
|
10851
|
+
|| type === "fleet_list_stickers" || type === "fleet_preview_stickers" ? "fleet_persona_emoji_response"
|
|
10852
|
+
: "fleet_description_response";
|
|
10019
10853
|
ipc.send({ type: responseType, fleetRequestId, error: message });
|
|
10020
10854
|
}
|
|
10021
10855
|
dispatchTypedIpc(name, msg) {
|
|
@@ -10407,14 +11241,20 @@ export class FleetManager {
|
|
|
10407
11241
|
return (v === "low" || v === "normal" || v === "high" || v === "urgent") ? v : undefined;
|
|
10408
11242
|
};
|
|
10409
11243
|
const asStatus = (v) => {
|
|
10410
|
-
return (v === "open" || v === "claimed" || v === "done" || v === "blocked" || v === "cancelled") ? v : undefined;
|
|
11244
|
+
return (v === "open" || v === "claimed" || v === "review" || v === "done" || v === "blocked" || v === "cancelled") ? v : undefined;
|
|
10411
11245
|
};
|
|
10412
11246
|
// #1336 P2: use the shared normalizer — trims whitespace AND drops empties,
|
|
10413
11247
|
// so " \t " / [" ","\t"] are treated identically to undefined.
|
|
10414
11248
|
const asStatusFilter = normalizeStatusFilter;
|
|
10415
11249
|
// #1336: small write ack — identifying fields only.
|
|
10416
11250
|
const ack = (t) => ({ id: t.id, status: t.status, updated_at: t.updated_at });
|
|
11251
|
+
// The board's notes for the write (a send-back reason, a comment the assignee had not seen): with the ack when any.
|
|
11252
|
+
const noted = (a) => { const notes = db.board.takeNotes(); return notes.length ? { ...a, notes } : a; };
|
|
10417
11253
|
try {
|
|
11254
|
+
// The task board's own actions and fields (task-board-tool.ts); the rest is the tool's plain task, below.
|
|
11255
|
+
const onBoard = boardToolAction(db, instance, args);
|
|
11256
|
+
if (onBoard !== undefined)
|
|
11257
|
+
return onBoard;
|
|
10418
11258
|
switch (action) {
|
|
10419
11259
|
case "create": {
|
|
10420
11260
|
const title = asStr(args.title);
|
|
@@ -10453,24 +11293,24 @@ export class FleetManager {
|
|
|
10453
11293
|
const id = asStr(args.id);
|
|
10454
11294
|
if (!id)
|
|
10455
11295
|
return { error: "id is required" };
|
|
10456
|
-
return ack(db.claimTask(db.getTaskByPrefix(id).id, instance));
|
|
11296
|
+
return noted(ack(db.claimTask(db.getTaskByPrefix(id).id, instance, { kind: "instance", name: instance })));
|
|
10457
11297
|
}
|
|
10458
11298
|
case "done": {
|
|
10459
11299
|
const id = asStr(args.id);
|
|
10460
11300
|
if (!id)
|
|
10461
11301
|
return { error: "id is required" };
|
|
10462
|
-
return ack(db.completeTask(db.getTaskByPrefix(id).id, asStr(args.result)));
|
|
11302
|
+
return noted(ack(db.completeTask(db.getTaskByPrefix(id).id, asStr(args.result), { kind: "instance", name: instance })));
|
|
10463
11303
|
}
|
|
10464
11304
|
case "update": {
|
|
10465
11305
|
const id = asStr(args.id);
|
|
10466
11306
|
if (!id)
|
|
10467
11307
|
return { error: "id is required" };
|
|
10468
|
-
return ack(db.updateTask(db.getTaskByPrefix(id).id, {
|
|
11308
|
+
return noted(ack(db.updateTask(db.getTaskByPrefix(id).id, {
|
|
10469
11309
|
status: asStatus(args.status),
|
|
10470
11310
|
assignee: asStr(args.assignee),
|
|
10471
11311
|
result: asStr(args.result),
|
|
10472
11312
|
priority: asPriority(args.priority),
|
|
10473
|
-
}));
|
|
11313
|
+
}, { kind: "instance", name: instance })));
|
|
10474
11314
|
}
|
|
10475
11315
|
default: return { error: `Unknown task action: ${action}` };
|
|
10476
11316
|
}
|
|
@@ -10502,6 +11342,8 @@ export class FleetManager {
|
|
|
10502
11342
|
}
|
|
10503
11343
|
/** #1629: `agend-agent theme …` — the same service as the tools; `instance` is the token's. */
|
|
10504
11344
|
async handleWebThemeHttp(instance, tool, args) {
|
|
11345
|
+
if (isCanvasWorkerTool(tool) && !this.canvasWorkerEnabled())
|
|
11346
|
+
return { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE };
|
|
10505
11347
|
if (WEB_EFFECT_TOOLS.has(tool)) {
|
|
10506
11348
|
if (!this.isKnownInstance(instance))
|
|
10507
11349
|
return { error: "unknown_instance" };
|
|
@@ -10564,7 +11406,18 @@ export class FleetManager {
|
|
|
10564
11406
|
// so " \t " / [" ","\t"] are treated identically to undefined.
|
|
10565
11407
|
const asStatusFilter = normalizeStatusFilter;
|
|
10566
11408
|
const ack = (t) => ({ id: t.id, status: t.status, updated_at: t.updated_at });
|
|
11409
|
+
const noted = (a) => { const notes = db.board.takeNotes(); return notes.length ? { ...a, notes } : a; };
|
|
10567
11410
|
try {
|
|
11411
|
+
// The task board's own actions and fields (task-board-tool.ts); the rest is the tool's plain task, below.
|
|
11412
|
+
const onBoard = boardToolAction(db, meta.instance_name || instanceName, payload);
|
|
11413
|
+
if (onBoard !== undefined) {
|
|
11414
|
+
ipc.send({ type: "fleet_task_response", fleetRequestId, result: onBoard });
|
|
11415
|
+
// The activity log hears of board work as it does of plain tasks.
|
|
11416
|
+
const what = boardActivity(payload);
|
|
11417
|
+
if (what)
|
|
11418
|
+
this.eventLog?.logActivity("task_update", instanceName, what);
|
|
11419
|
+
return;
|
|
11420
|
+
}
|
|
10568
11421
|
let result;
|
|
10569
11422
|
// Full task kept for activity logging; the IPC reply uses the compact ack.
|
|
10570
11423
|
let logTask;
|
|
@@ -10600,12 +11453,12 @@ export class FleetManager {
|
|
|
10600
11453
|
result = db.getTaskByPrefix(payload.id);
|
|
10601
11454
|
break;
|
|
10602
11455
|
case "claim":
|
|
10603
|
-
logTask = db.claimTask(db.getTaskByPrefix(payload.id).id, meta.instance_name || instanceName);
|
|
10604
|
-
result = ack(logTask);
|
|
11456
|
+
logTask = db.claimTask(db.getTaskByPrefix(payload.id).id, meta.instance_name || instanceName, { kind: "instance", name: meta.instance_name || instanceName });
|
|
11457
|
+
result = noted(ack(logTask));
|
|
10605
11458
|
break;
|
|
10606
11459
|
case "done":
|
|
10607
|
-
logTask = db.completeTask(db.getTaskByPrefix(payload.id).id, payload.result);
|
|
10608
|
-
result = ack(logTask);
|
|
11460
|
+
logTask = db.completeTask(db.getTaskByPrefix(payload.id).id, payload.result, { kind: "instance", name: meta.instance_name || instanceName });
|
|
11461
|
+
result = noted(ack(logTask));
|
|
10609
11462
|
break;
|
|
10610
11463
|
case "update":
|
|
10611
11464
|
logTask = db.updateTask(db.getTaskByPrefix(payload.id).id, {
|
|
@@ -10613,8 +11466,8 @@ export class FleetManager {
|
|
|
10613
11466
|
assignee: payload.assignee,
|
|
10614
11467
|
result: payload.result,
|
|
10615
11468
|
priority: payload.priority,
|
|
10616
|
-
});
|
|
10617
|
-
result = ack(logTask);
|
|
11469
|
+
}, { kind: "instance", name: meta.instance_name || instanceName });
|
|
11470
|
+
result = noted(ack(logTask));
|
|
10618
11471
|
break;
|
|
10619
11472
|
default:
|
|
10620
11473
|
throw new Error(`Unknown task action: ${action}`);
|
|
@@ -10929,6 +11782,7 @@ export class FleetManager {
|
|
|
10929
11782
|
saveFleetConfig(explicitPatches = []) {
|
|
10930
11783
|
this.publicWebLink?.refresh();
|
|
10931
11784
|
this.enforceGuestSwitch(); // #1920: a Settings save turning guests off ends their sessions
|
|
11785
|
+
this.observeWebAuthority(); // #2453 review: so is a web-authority switch (off is always seen)
|
|
10932
11786
|
if (!this.fleetConfig || !this.configPath)
|
|
10933
11787
|
return;
|
|
10934
11788
|
if (!this.savedFleetConfigSnapshot)
|
|
@@ -10941,6 +11795,10 @@ export class FleetManager {
|
|
|
10941
11795
|
throw new Error(`Refusing to overwrite invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
|
|
10942
11796
|
}
|
|
10943
11797
|
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
11798
|
+
// #2208: what the file says before this save, aliases expanded — every write below is recorded, and anything that
|
|
11799
|
+
// changes outside them (a place sharing a node with one written) refuses the save.
|
|
11800
|
+
const fileBefore = this.rawFleetDocument.toJS({ maxAliasCount: -1 });
|
|
11801
|
+
const writes = this.fleetDocumentWrites = []; // a throw below leaves it to the next save
|
|
10944
11802
|
this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
|
|
10945
11803
|
// Settings edits are expressed against the raw config, so apply them before
|
|
10946
11804
|
// canonicalization. A non-identity value equal to its inherited default is
|
|
@@ -10951,6 +11809,7 @@ export class FleetManager {
|
|
|
10951
11809
|
// (or a legacy scalar occupies that parent). Removing an override which
|
|
10952
11810
|
// is already absent is an idempotent no-op, not a failed Settings save.
|
|
10953
11811
|
if (this.rawFleetDocument.hasIn(patch.path)) {
|
|
11812
|
+
this.writingFleetDocument(this.rawFleetDocument, patch.path);
|
|
10954
11813
|
this.rawFleetDocument.deleteIn(patch.path);
|
|
10955
11814
|
}
|
|
10956
11815
|
}
|
|
@@ -10962,8 +11821,10 @@ export class FleetManager {
|
|
|
10962
11821
|
const rawAfterPatches = this.rawFleetDocument.toJS();
|
|
10963
11822
|
const redundantPaths = collectRedundantInstanceDefaultPaths(rawAfterPatches);
|
|
10964
11823
|
for (const path of redundantPaths) {
|
|
10965
|
-
if (this.rawFleetDocument.hasIn(path))
|
|
11824
|
+
if (this.rawFleetDocument.hasIn(path)) {
|
|
11825
|
+
this.writingFleetDocument(this.rawFleetDocument, path);
|
|
10966
11826
|
this.rawFleetDocument.deleteIn(path);
|
|
11827
|
+
}
|
|
10967
11828
|
// Avoid leaving empty operational maps such as `terminal: {}` while
|
|
10968
11829
|
// preserving the instance mapping itself and all surrounding comments.
|
|
10969
11830
|
for (let depth = path.length - 1; depth > 2; depth--) {
|
|
@@ -10971,9 +11832,14 @@ export class FleetManager {
|
|
|
10971
11832
|
const parent = this.rawFleetDocument.getIn(parentPath, true);
|
|
10972
11833
|
if (!isMap(parent) || parent.items.length > 0)
|
|
10973
11834
|
break;
|
|
11835
|
+
this.writingFleetDocument(this.rawFleetDocument, parentPath);
|
|
10974
11836
|
this.rawFleetDocument.deleteIn(parentPath);
|
|
10975
11837
|
}
|
|
10976
11838
|
}
|
|
11839
|
+
this.fleetDocumentWrites = null;
|
|
11840
|
+
const stray = changedOutside(fileBefore, this.rawFleetDocument.toJS({ maxAliasCount: -1 }), writes);
|
|
11841
|
+
if (stray)
|
|
11842
|
+
throw new Error(`Refusing to save fleet.yaml: this change would also change ${stray}, which shares a value with what it edits. Give each its own value in fleet.yaml.`);
|
|
10977
11843
|
noteSettingsWrite(this.configPath, this.savedFleetConfigSnapshot, this.fleetConfig);
|
|
10978
11844
|
const output = String(this.rawFleetDocument);
|
|
10979
11845
|
if (redundantPaths.length > 0)
|
|
@@ -11084,11 +11950,15 @@ export class FleetManager {
|
|
|
11084
11950
|
// Remove from the end so YAML sequence indexes do not shift underneath us.
|
|
11085
11951
|
for (let i = before.length - 1; i >= after.length; i--) {
|
|
11086
11952
|
const itemPath = [...path, i];
|
|
11087
|
-
if (document.hasIn(itemPath))
|
|
11953
|
+
if (document.hasIn(itemPath)) {
|
|
11954
|
+
this.writingFleetDocument(document, itemPath);
|
|
11088
11955
|
document.deleteIn(itemPath);
|
|
11956
|
+
}
|
|
11089
11957
|
}
|
|
11090
|
-
for (let i = shared; i < after.length; i++)
|
|
11958
|
+
for (let i = shared; i < after.length; i++) {
|
|
11959
|
+
this.writingFleetDocument(document, [...path, i]);
|
|
11091
11960
|
document.setIn([...path, i], after[i]);
|
|
11961
|
+
}
|
|
11092
11962
|
return;
|
|
11093
11963
|
}
|
|
11094
11964
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -11104,6 +11974,7 @@ export class FleetManager {
|
|
|
11104
11974
|
if (isMap(fresh)) {
|
|
11105
11975
|
const list = document.createNode([]);
|
|
11106
11976
|
list.items.push(fresh.clone());
|
|
11977
|
+
this.writingFleetDocument(document, ["channels"]);
|
|
11107
11978
|
document.setIn(["channels"], list);
|
|
11108
11979
|
}
|
|
11109
11980
|
}
|
|
@@ -11125,13 +11996,17 @@ export class FleetManager {
|
|
|
11125
11996
|
// Effective config contains inherited objects that may not exist in the
|
|
11126
11997
|
// raw YAML at all. yaml.deleteIn() is not idempotent for a missing nested
|
|
11127
11998
|
// parent, so guard it explicitly.
|
|
11128
|
-
if (document.hasIn(path))
|
|
11999
|
+
if (document.hasIn(path)) {
|
|
12000
|
+
this.writingFleetDocument(document, path);
|
|
11129
12001
|
document.deleteIn(path);
|
|
12002
|
+
}
|
|
11130
12003
|
}
|
|
11131
12004
|
else if (path.length === 0) {
|
|
12005
|
+
this.writingFleetDocument(document, path);
|
|
11132
12006
|
document.contents = document.createNode(after);
|
|
11133
12007
|
}
|
|
11134
12008
|
else {
|
|
12009
|
+
this.writingFleetDocument(document, path);
|
|
11135
12010
|
const currentNode = document.getIn(path, true);
|
|
11136
12011
|
if (isScalar(currentNode) && (after === null || typeof after !== "object")) {
|
|
11137
12012
|
currentNode.value = after;
|
|
@@ -11146,6 +12021,15 @@ export class FleetManager {
|
|
|
11146
12021
|
}
|
|
11147
12022
|
}
|
|
11148
12023
|
}
|
|
12024
|
+
/**
|
|
12025
|
+
* #2208: before every write to the raw document — the place is unshared first (an alias on the way becomes its own
|
|
12026
|
+
* copy; an anchor's other aliases get theirs), so the write changes that place only; and it is recorded for the
|
|
12027
|
+
* save's check that nothing else changed.
|
|
12028
|
+
*/
|
|
12029
|
+
writingFleetDocument(document, path) {
|
|
12030
|
+
detachForWrite(document, path);
|
|
12031
|
+
this.fleetDocumentWrites?.push([...path]);
|
|
12032
|
+
}
|
|
11149
12033
|
getSettingsConfigPath() { return this.configPath; }
|
|
11150
12034
|
async removeInstance(name, authorization, execution) {
|
|
11151
12035
|
if (execution)
|
|
@@ -11189,10 +12073,27 @@ export class FleetManager {
|
|
|
11189
12073
|
// Capture instance dir BEFORE lifecycle.remove, which deletes the instance
|
|
11190
12074
|
// from fleetConfig. (The backend would be unresolvable afterwards.)
|
|
11191
12075
|
const instanceDirToClean = this.getInstanceDir(name);
|
|
11192
|
-
|
|
11193
|
-
|
|
11194
|
-
|
|
11195
|
-
|
|
12076
|
+
// #1774: replies sent from here on are a later instance's (a replacement registered while this one goes); the ones
|
|
12077
|
+
// sent before wait for the outcome before they are shown.
|
|
12078
|
+
const removedBelow = this.webChatGeneration(name) + 1, removedDaemon = this.daemons.get(name);
|
|
12079
|
+
this.webChatGenerations.set(name, removedBelow);
|
|
12080
|
+
let settleRemoval;
|
|
12081
|
+
const removalPending = { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}), done: new Promise(r => { settleRemoval = r; }) };
|
|
12082
|
+
this.webChatRemovalsPending.set(name, removalPending);
|
|
12083
|
+
try {
|
|
12084
|
+
if (execution)
|
|
12085
|
+
await this.lifecycle.remove(name, authorization, execution, transition);
|
|
12086
|
+
else
|
|
12087
|
+
await this.lifecycle.remove(name, authorization);
|
|
12088
|
+
// #1774: removed — its replies still copying their files are no one's to show, replaced or not (a failed removal
|
|
12089
|
+
// throws above: its replies stay its own).
|
|
12090
|
+
this.webChatRemovals.set(name, { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}) });
|
|
12091
|
+
}
|
|
12092
|
+
finally {
|
|
12093
|
+
if (this.webChatRemovalsPending.get(name) === removalPending)
|
|
12094
|
+
this.webChatRemovalsPending.delete(name);
|
|
12095
|
+
settleRemoval();
|
|
12096
|
+
}
|
|
11196
12097
|
// A later same-name registration owns its files and caches.
|
|
11197
12098
|
// Own keys only: an instance named "constructor" is removed as any other (its inherited namesake is not a
|
|
11198
12099
|
// re-registration) — #1677 review.
|
|
@@ -11207,6 +12108,7 @@ export class FleetManager {
|
|
|
11207
12108
|
// Its web chat goes with it (only after the removal succeeded): a later instance of the same name
|
|
11208
12109
|
// must not be shown the old one's conversation, and deleted names must not pile up.
|
|
11209
12110
|
this.webChatHistory.forget(name);
|
|
12111
|
+
this.searchIndex?.removeInstance(name);
|
|
11210
12112
|
this.webFiles.forget(name);
|
|
11211
12113
|
this.compactWatchService?.forget(name);
|
|
11212
12114
|
// #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
|
|
@@ -11887,7 +12789,7 @@ export class FleetManager {
|
|
|
11887
12789
|
/** A preview under the inbox, named by a hash of the id (a Telegram file_id is long), pruned after a day. */
|
|
11888
12790
|
storeStickerPreview(id, picture) {
|
|
11889
12791
|
const dir = join(this.dataDir, "inbox", "sticker-previews");
|
|
11890
|
-
mkdirSync(dir, { recursive: true });
|
|
12792
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
11891
12793
|
const now = Date.now();
|
|
11892
12794
|
for (const name of readdirSync(dir)) {
|
|
11893
12795
|
try {
|
|
@@ -11953,7 +12855,7 @@ export class FleetManager {
|
|
|
11953
12855
|
if (!/^\d{15,25}$/.test(id))
|
|
11954
12856
|
throw new Error("not a Discord emoji id");
|
|
11955
12857
|
const dir = join(this.dataDir, "inbox", "emoji-previews");
|
|
11956
|
-
mkdirSync(dir, { recursive: true });
|
|
12858
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
11957
12859
|
const now = Date.now();
|
|
11958
12860
|
for (const name of readdirSync(dir)) {
|
|
11959
12861
|
try {
|
|
@@ -12352,7 +13254,7 @@ export class FleetManager {
|
|
|
12352
13254
|
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
|
12353
13255
|
const backend = this.fleetConfig?.defaults?.backend || "claude-code";
|
|
12354
13256
|
try {
|
|
12355
|
-
const { execFile } = await import("
|
|
13257
|
+
const { execFile } = await import("./child-process.js");
|
|
12356
13258
|
const { promisify } = await import("node:util");
|
|
12357
13259
|
// execFile with an argv array — no shell, so the backend name cannot be
|
|
12358
13260
|
// interpreted as a command even if config is malformed.
|
|
@@ -12578,6 +13480,51 @@ export class FleetManager {
|
|
|
12578
13480
|
}
|
|
12579
13481
|
return { chatId, opts: {} };
|
|
12580
13482
|
}
|
|
13483
|
+
/**
|
|
13484
|
+
* #2246 step 3: the task board's daily summary of [since, until) (task-board-summary.ts). Each chat world's General
|
|
13485
|
+
* gets its own instances' cards — a card that no world's instance holds (nobody's, a person's) goes to the primary
|
|
13486
|
+
* one — and each of that world's Generals shows the same text in the web chat. A world with nothing to say gets
|
|
13487
|
+
* nothing. A web-only fleet: everything, in the web chat.
|
|
13488
|
+
*/
|
|
13489
|
+
postTaskBoardSummary(since, until) {
|
|
13490
|
+
const board = this.scheduler?.db.board;
|
|
13491
|
+
if (!board)
|
|
13492
|
+
return;
|
|
13493
|
+
const read = board.summaryRead(since.toISOString(), until.toISOString(), this.dispatcherGone);
|
|
13494
|
+
const projects = board.listProjects(true);
|
|
13495
|
+
const stats = board.projectStats();
|
|
13496
|
+
const names = new Map(projects.map((p) => [p.id, p.name]));
|
|
13497
|
+
const configured = this.getPrimaryAdapterId();
|
|
13498
|
+
const primary = configured !== undefined && this.worlds.has(configured) ? configured : this.worlds.keys().next().value;
|
|
13499
|
+
const worldOf = (assignee) => {
|
|
13500
|
+
const owner = assignee ? this.getInstanceAdapterId(assignee) : undefined;
|
|
13501
|
+
return owner !== undefined && this.worlds.has(owner) ? owner : primary;
|
|
13502
|
+
};
|
|
13503
|
+
const zone = this.usageZone();
|
|
13504
|
+
for (const world of this.worlds.size ? [...this.worlds.keys()] : [undefined]) {
|
|
13505
|
+
const summary = buildSummary(read, since, until, { inChat: (assignee) => worldOf(assignee) === world, projects, stats });
|
|
13506
|
+
if (isQuiet(summary))
|
|
13507
|
+
continue;
|
|
13508
|
+
const text = renderSummary(summary, { zone, t: (key, ...args) => t(key, ...args), projectOf: (c) => (c.projectId === null ? null : names.get(c.projectId) ?? null) });
|
|
13509
|
+
const cards = [...new Set([...summary.review, ...summary.blocked, ...summary.done, ...summary.created].map((c) => c.code))];
|
|
13510
|
+
if (world !== undefined) {
|
|
13511
|
+
const adapter = this.worlds.get(world)?.adapter;
|
|
13512
|
+
const target = this.fleetNoticeTarget(world);
|
|
13513
|
+
if (adapter && target) {
|
|
13514
|
+
adapter.sendText(target.chatId, text, target.opts).catch(e => this.logger.warn({ err: e, world }, "Failed to send the task board's daily summary"));
|
|
13515
|
+
}
|
|
13516
|
+
else {
|
|
13517
|
+
this.logger.warn({ world }, "The task board's daily summary has no postable target — set a General topic or channel.options.general_channel_id");
|
|
13518
|
+
}
|
|
13519
|
+
}
|
|
13520
|
+
for (const [name, cfg] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
13521
|
+
if (cfg.general_topic !== true || (world !== undefined && this.getInstanceAdapterId(name) !== world))
|
|
13522
|
+
continue;
|
|
13523
|
+
// #2341 review: with the cards it names, so the public link withholds it while one of them is hidden there.
|
|
13524
|
+
this.emitSseEvent("message", { instance: name, sender: name, role: "status", text, ts: new Date().toISOString(), cards });
|
|
13525
|
+
}
|
|
13526
|
+
}
|
|
13527
|
+
}
|
|
12581
13528
|
/**
|
|
12582
13529
|
* Post the daily summary where fleet-wide notices go.
|
|
12583
13530
|
*
|
|
@@ -13224,6 +14171,7 @@ export class FleetManager {
|
|
|
13224
14171
|
interactionSummary: p.interaction_summary ?? null,
|
|
13225
14172
|
pauseReason, pausedAt,
|
|
13226
14173
|
crashedAt: crashed ? (this.needsCrashedAt.get(name) ?? null) : null,
|
|
14174
|
+
signIn: pauseReason === "auth" ? this.signInFor(name) : null,
|
|
13227
14175
|
});
|
|
13228
14176
|
}
|
|
13229
14177
|
return out;
|
|
@@ -13285,6 +14233,8 @@ export class FleetManager {
|
|
|
13285
14233
|
return String(topic);
|
|
13286
14234
|
return this.classicChannels?.getChannelIdByInstance(instance) ?? undefined;
|
|
13287
14235
|
},
|
|
14236
|
+
laneTarget: instance => this.instanceChannelTarget(instance),
|
|
14237
|
+
taskRoute: code => this.needsYouRoute(code),
|
|
13288
14238
|
isFleetAdmin: (userId, world) => this.isFleetAdmin(userId, world),
|
|
13289
14239
|
fleetAdmins: world => (this.getChannelConfig(world)?.access?.allowed_users ?? []).map(String),
|
|
13290
14240
|
emitSse: (event, data) => this.emitSseEvent(event, data),
|
|
@@ -13293,6 +14243,8 @@ export class FleetManager {
|
|
|
13293
14243
|
throw new Error("the delivery outbox is not open");
|
|
13294
14244
|
return this.deliveryOutbox.acknowledge(deliveryId, by);
|
|
13295
14245
|
},
|
|
14246
|
+
tasks: () => this.needsYouTaskItems(),
|
|
14247
|
+
taskAction: action => this.taskActionFromChat(action),
|
|
13296
14248
|
settings: () => ({
|
|
13297
14249
|
liveMessage: this.fleetConfig?.needs_you?.live_message !== false,
|
|
13298
14250
|
dm: this.fleetConfig?.needs_you?.dm === true,
|
|
@@ -13303,6 +14255,114 @@ export class FleetManager {
|
|
|
13303
14255
|
});
|
|
13304
14256
|
this.needsYou.start();
|
|
13305
14257
|
}
|
|
14258
|
+
/**
|
|
14259
|
+
* #2352: where an instance's own notices go — its topic in its world's group, or its ClassicBot room — as a Needs you
|
|
14260
|
+
* lane's place; null when it has neither (or its world has no group).
|
|
14261
|
+
*/
|
|
14262
|
+
instanceChannelTarget(instance) {
|
|
14263
|
+
const cfg = this.fleetConfig?.instances && Object.hasOwn(this.fleetConfig.instances, instance) ? this.fleetConfig.instances[instance] : undefined;
|
|
14264
|
+
if (!cfg)
|
|
14265
|
+
return null;
|
|
14266
|
+
const groupId = this.getChannelConfig(this.getInstanceAdapterId(instance))?.group_id;
|
|
14267
|
+
if (cfg.topic_id != null && groupId != null)
|
|
14268
|
+
return { chatId: String(groupId), threadId: String(cfg.topic_id) };
|
|
14269
|
+
const classic = this.classicChannels?.getChannelIdByInstance(instance);
|
|
14270
|
+
return classic ? { chatId: classic } : null;
|
|
14271
|
+
}
|
|
14272
|
+
/**
|
|
14273
|
+
* #2352: the instance whose own channel lists a waiting card — its project's lead — when that channel can be used now
|
|
14274
|
+
* (a fleet instance of a live world, with a topic or room). undefined: its assignee's world's General, as before.
|
|
14275
|
+
* #2434: only a card the person handed out waits for them, so the instance that handed one out is never its lane.
|
|
14276
|
+
*/
|
|
14277
|
+
needsYouLane(card, leads) {
|
|
14278
|
+
const usable = (name) => {
|
|
14279
|
+
if (!name || !this.fleetConfig?.instances || !Object.hasOwn(this.fleetConfig.instances, name))
|
|
14280
|
+
return false;
|
|
14281
|
+
const world = this.getInstanceAdapterId(name);
|
|
14282
|
+
return world !== undefined && this.worlds.has(world) && this.instanceChannelTarget(name) !== null;
|
|
14283
|
+
};
|
|
14284
|
+
const lead = card.projectId != null ? leads.get(card.projectId) : null;
|
|
14285
|
+
return usable(lead) ? lead : undefined;
|
|
14286
|
+
}
|
|
14287
|
+
/**
|
|
14288
|
+
* #2355 review: where a card's Needs you buttons belong now — read from the board and the fleet at the moment of a click
|
|
14289
|
+
* or a reply, never from the last render. null: it no longer waits for the person.
|
|
14290
|
+
*/
|
|
14291
|
+
/**
|
|
14292
|
+
* #2425 follow-up: an instance that handed out a card and is no longer in the fleet — neither a configured instance nor
|
|
14293
|
+
* a ClassicBot identity (deleted). Its blocked and in-review cards fall back to the person (Needs you, the summary).
|
|
14294
|
+
* Not known before the config and the ClassicBot rooms are loaded: then no one is gone (a live dispatcher's cards never
|
|
14295
|
+
* flash into the person's list at startup).
|
|
14296
|
+
*/
|
|
14297
|
+
dispatcherGone = (name) => {
|
|
14298
|
+
const instances = this.fleetConfig?.instances, classic = this.classicChannels;
|
|
14299
|
+
if (!instances || !classic)
|
|
14300
|
+
return false;
|
|
14301
|
+
return !Object.hasOwn(instances, name) && !classic.getAll().some((ch) => ch.instanceName === name);
|
|
14302
|
+
};
|
|
14303
|
+
needsYouRoute(code) {
|
|
14304
|
+
const board = this.scheduler?.db.board;
|
|
14305
|
+
const card = board?.findCard(code);
|
|
14306
|
+
// #2425 (Fable): the same rule as the list — a button rendered before the fix (or before the opt-in went off) for a
|
|
14307
|
+
// card that no longer waits for the person answers "no longer waits".
|
|
14308
|
+
if (!board || !card || this.fleetConfig?.needs_you?.task_board !== true || !waitsForPerson(card, this.dispatcherGone))
|
|
14309
|
+
return null;
|
|
14310
|
+
const leads = new Map(board.listProjects(true).map((p) => [p.id, p.leadInstance]));
|
|
14311
|
+
const lane = this.needsYouLane(card, leads);
|
|
14312
|
+
return lane ? { lane } : {};
|
|
14313
|
+
}
|
|
14314
|
+
/**
|
|
14315
|
+
* #2246 step 3: the task board's cards that wait for the person, for Needs you — under each card's assignee, listed in
|
|
14316
|
+
* its lane's channel (#2352: its project's lead; #2434: never the instance that handed it out), else in General.
|
|
14317
|
+
*/
|
|
14318
|
+
needsYouTaskItems() {
|
|
14319
|
+
// #2425: the task board's cards join Needs you only when the fleet asks for them (needs_you.task_board, off by
|
|
14320
|
+
// default since 2.3.0-beta.25); prompts, deliveries and the rest are unchanged.
|
|
14321
|
+
return this.fleetConfig?.needs_you?.task_board === true ? this.needsYouTasks() : [];
|
|
14322
|
+
}
|
|
14323
|
+
needsYouTasks() {
|
|
14324
|
+
const board = this.scheduler?.db.board;
|
|
14325
|
+
if (!board)
|
|
14326
|
+
return [];
|
|
14327
|
+
const leads = new Map(board.listProjects(true).map((p) => [p.id, p.leadInstance]));
|
|
14328
|
+
return board.waitingForPerson(this.dispatcherGone).map(({ card, since }) => {
|
|
14329
|
+
const gap = card.status === "review" ? wrapUpGap(card) : null;
|
|
14330
|
+
const lane = this.needsYouLane(card, leads);
|
|
14331
|
+
return {
|
|
14332
|
+
uuid: card.uuid, code: card.code, title: card.title, status: card.status, version: card.version,
|
|
14333
|
+
assignee: card.assignee, since: Date.parse(since) || 0, ...(gap ? { gap } : {}), ...(lane ? { lane } : {}),
|
|
14334
|
+
note: card.status === "blocked" ? card.blockedReason : gap ? t("needs.task_gap", [
|
|
14335
|
+
gap.unchecked ? t("needs.task_gap_unchecked", gap.unchecked) : "", gap.noResult ? t("needs.task_gap_no_result") : "",
|
|
14336
|
+
].filter(Boolean).join(t("needs.task_gap_join"))) : "",
|
|
14337
|
+
};
|
|
14338
|
+
});
|
|
14339
|
+
}
|
|
14340
|
+
/**
|
|
14341
|
+
* #2246 step 3: the person's move on a card from a chat's Needs you list — a fleet admin of that world (checked by the
|
|
14342
|
+
* hub), acting as the person, through the board's rules, at the version the button showed.
|
|
14343
|
+
*/
|
|
14344
|
+
taskActionFromChat(action) {
|
|
14345
|
+
const board = this.scheduler?.db.board;
|
|
14346
|
+
if (!board)
|
|
14347
|
+
return { ok: false, message: t("needs.task_board_closed") };
|
|
14348
|
+
const person = { kind: "person", name: action.by.username || `${action.by.platform}:${action.by.userId}` };
|
|
14349
|
+
try {
|
|
14350
|
+
if (action.verb === "confirm")
|
|
14351
|
+
board.moveCard(person, action.code, "done", { version: action.version });
|
|
14352
|
+
else if (action.verb === "back")
|
|
14353
|
+
board.moveCard(person, action.code, "claimed", { version: action.version, reason: action.text });
|
|
14354
|
+
else
|
|
14355
|
+
board.moveCard(person, action.code, "claimed", { version: action.version, comment: action.text });
|
|
14356
|
+
const done = { confirm: "needs.task_confirmed", back: "needs.task_sent_back", answer: "needs.task_answered" }[action.verb];
|
|
14357
|
+
this.logger.info({ code: action.code, verb: action.verb, by: `${action.by.platform}:${action.by.userId}` }, "Task board: the person moved a card from the chat");
|
|
14358
|
+
return { ok: true, message: t(done, action.code) };
|
|
14359
|
+
}
|
|
14360
|
+
catch (err) {
|
|
14361
|
+
if (err instanceof TaskBoardError && err.code === "conflict")
|
|
14362
|
+
return { ok: false, message: t("needs.task_changed", action.code) };
|
|
14363
|
+
return { ok: false, message: t("needs.task_refused", action.code, err.message) };
|
|
14364
|
+
}
|
|
14365
|
+
}
|
|
13306
14366
|
/** The prompts open on the dashboard right now (a page that loads after one was posted asks for them). */
|
|
13307
14367
|
listWebPrompts() {
|
|
13308
14368
|
const open = [];
|
|
@@ -13700,7 +14760,7 @@ export class FleetManager {
|
|
|
13700
14760
|
...(input.instance !== undefined ? { target: { adapterId: this.getInstanceAdapterId(input.instance) } } : {}),
|
|
13701
14761
|
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
13702
14762
|
authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
|
|
13703
|
-
policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now: Date.now(),
|
|
14763
|
+
policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now: Date.now(),
|
|
13704
14764
|
});
|
|
13705
14765
|
}
|
|
13706
14766
|
sessionAuthorizations = null;
|
|
@@ -13725,7 +14785,7 @@ export class FleetManager {
|
|
|
13725
14785
|
session, gateway, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
13726
14786
|
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
13727
14787
|
authorization: { handle: session.handle, owner: session.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
|
|
13728
|
-
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now,
|
|
14788
|
+
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
|
|
13729
14789
|
});
|
|
13730
14790
|
if (check.refusal)
|
|
13731
14791
|
return { ok: false, refusal: check.refusal };
|
|
@@ -13740,9 +14800,30 @@ export class FleetManager {
|
|
|
13740
14800
|
*/
|
|
13741
14801
|
webAuthorityState() {
|
|
13742
14802
|
if (!this.dataDir)
|
|
13743
|
-
return { enabled: false, loopbackOnly: true, epoch: 0 };
|
|
14803
|
+
return { enabled: false, loopbackOnly: true, namedLink: false, epoch: 0 };
|
|
13744
14804
|
this.authorityStateStore ??= new AuthorityStateStore(join(this.dataDir, "web-authority.json"), this.logger, undefined, undefined, () => { this.ensureDeliveryOutbox?.(); return this.deliveryOutbox?.maxWebAuthorityEpoch() ?? 0; });
|
|
13745
|
-
|
|
14805
|
+
const state = this.authorityStateStore.current(this.fleetConfig?.web?.verified_authority);
|
|
14806
|
+
// #2453 (§0.2): the named-link option seen going off — the authority of every session over the link ends now, for
|
|
14807
|
+
// good: their authorizations go (a later "on" can never bring one back without its account's Confirm).
|
|
14808
|
+
if (state.namedLinkClosed)
|
|
14809
|
+
this.sessionAuthorizationStore()?.prune(a => this.webSessions?.findByHandle(a.handle)?.surface === "local");
|
|
14810
|
+
return state;
|
|
14811
|
+
}
|
|
14812
|
+
/**
|
|
14813
|
+
* #2453 review: the web-authority state read on every configuration change (a Settings save, the host's confirm, a
|
|
14814
|
+
* reload) — so a switch turned off and on again between two requests is still seen off: its epoch moves, and the
|
|
14815
|
+
* named-link sessions' authorizations are dropped. Reads that only happen on a request would miss it.
|
|
14816
|
+
*/
|
|
14817
|
+
observeWebAuthority() {
|
|
14818
|
+
// Only once the state is in use: any authorization was given through a read that made it (and a first read at
|
|
14819
|
+
// start-up still sees what changed while the fleet was down). Nothing new is opened during a load.
|
|
14820
|
+
try {
|
|
14821
|
+
if (this.dataDir && this.authorityStateStore)
|
|
14822
|
+
this.webAuthorityState();
|
|
14823
|
+
}
|
|
14824
|
+
catch (err) {
|
|
14825
|
+
this.logger.warn({ err: err?.message }, "Web authority state could not be read after a configuration change");
|
|
14826
|
+
}
|
|
13746
14827
|
}
|
|
13747
14828
|
/**
|
|
13748
14829
|
* #1939 S2a: Settings → Web → Web authority — the state in force (the switch counts as off while its state cannot be
|
|
@@ -13757,8 +14838,10 @@ export class FleetManager {
|
|
|
13757
14838
|
const authorized = state.enabled ? (this.sessionAuthorizationStore()?.list() ?? [])
|
|
13758
14839
|
.filter(a => a.epoch === state.epoch && sessions.has(a.handle) && now <= Math.min(a.until, a.authorizedAt + maxMs))
|
|
13759
14840
|
.map(a => ({ handle: a.handle, label: sessions.get(a.handle).label, account: `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
|
|
13760
|
-
until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry)
|
|
13761
|
-
|
|
14841
|
+
until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry),
|
|
14842
|
+
...(sessions.get(a.handle).surface === "gateway" ? { named: true } : {}) })) : [];
|
|
14843
|
+
const host = this.fleetConfig?.web?.public_link?.hostname;
|
|
14844
|
+
return { state, configured, hours: VERIFIED_SESSION_HOURS, lanHosts, authorized, namedHost: typeof host === "string" && host ? host : null };
|
|
13762
14845
|
}
|
|
13763
14846
|
authorizationRequests = null;
|
|
13764
14847
|
/**
|
|
@@ -13771,7 +14854,9 @@ export class FleetManager {
|
|
|
13771
14854
|
const adapterOf = (adapterId) => this.adapters.get(adapterId) ?? this.worlds.get(adapterId)?.adapter;
|
|
13772
14855
|
// The host's clock, with its zone: the person may read the DM on a phone in another one.
|
|
13773
14856
|
const when = (ms) => new Date(ms).toLocaleString(undefined, { timeZoneName: "short" });
|
|
13774
|
-
const where = (peer) =>
|
|
14857
|
+
const where = (peer) => peer === "named_link"
|
|
14858
|
+
? t("web_auth.peer_named_link", String(this.fleetConfig?.web?.public_link?.hostname ?? "?")) // §0.2: the internet
|
|
14859
|
+
: t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
|
|
13775
14860
|
return this.authorizationRequests = new SessionAuthorizationRequests({
|
|
13776
14861
|
now: () => performance.now(), wallNow: () => Date.now(),
|
|
13777
14862
|
maxSessionMs: () => webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs,
|
|
@@ -13805,7 +14890,8 @@ export class FleetManager {
|
|
|
13805
14890
|
const record = this.webSessions?.findByHandle(handle);
|
|
13806
14891
|
if (!record)
|
|
13807
14892
|
return { ok: false, refusal: "session_gone" };
|
|
13808
|
-
|
|
14893
|
+
// §0.2: a named-link session is judged as the gateway request it is (its exposure still the current one).
|
|
14894
|
+
const r = this.authorizeWebSession(record, peer, peer === "named_link");
|
|
13809
14895
|
// What the card says: the end of the authorization, or the session's own end when that comes first.
|
|
13810
14896
|
return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
|
|
13811
14897
|
},
|
|
@@ -13851,7 +14937,7 @@ export class FleetManager {
|
|
|
13851
14937
|
session: s, gateway: input.gateway, peer: input.peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
13852
14938
|
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
13853
14939
|
authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
|
|
13854
|
-
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now,
|
|
14940
|
+
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
|
|
13855
14941
|
});
|
|
13856
14942
|
return r.refusal;
|
|
13857
14943
|
}
|
|
@@ -13934,7 +15020,7 @@ export class FleetManager {
|
|
|
13934
15020
|
const current = this.resolveInstanceEffort(instance).effort;
|
|
13935
15021
|
return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
|
|
13936
15022
|
},
|
|
13937
|
-
backendStep: async (instance, args) => {
|
|
15023
|
+
backendStep: async (instance, args, stepOpts) => {
|
|
13938
15024
|
const refusal = this.backendCommandRefusal(instance);
|
|
13939
15025
|
if (refusal)
|
|
13940
15026
|
return { error: refusal };
|
|
@@ -13944,7 +15030,13 @@ export class FleetManager {
|
|
|
13944
15030
|
return step.noop ? { text: step.text } : { error: step.text };
|
|
13945
15031
|
if (step.kind === "choose") {
|
|
13946
15032
|
const id = (token) => joinBackendArgs([...step.args, token]);
|
|
13947
|
-
return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c =>
|
|
15033
|
+
return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => {
|
|
15034
|
+
// #2233: the web offers Sign in beside it, so its reason is no longer "use /login".
|
|
15035
|
+
// Not over the public link: Sign in is refused there, so the choice keeps its "(use /login)" (#2262 review).
|
|
15036
|
+
const signIn = c.signIn && !stepOpts?.publicLink && dashboardSignInSupported(c.signIn) ? c.signIn : null;
|
|
15037
|
+
const disabled = signIn ? t("backend.reason_not_signed_in_web") : c.disabled;
|
|
15038
|
+
return { id: id(c.token), label: signIn ? `${c.token} — ${disabled}` : c.label, ...(disabled ? { disabled } : {}), ...(signIn ? { signIn } : {}) };
|
|
15039
|
+
}) } };
|
|
13948
15040
|
}
|
|
13949
15041
|
const now = performance.now();
|
|
13950
15042
|
for (const [k, v] of this.webBackendTokens)
|
|
@@ -14118,14 +15210,42 @@ export class FleetManager {
|
|
|
14118
15210
|
/** A clean exit is intentional from the CLI's perspective, but often not from
|
|
14119
15211
|
* the operator's. Keep the instance notice passive and put the action in the
|
|
14120
15212
|
* same-world General topic where an administrator can make the choice. */
|
|
14121
|
-
async
|
|
14122
|
-
this.
|
|
15213
|
+
async notifySessionOomStop(instanceName, unclear = false) {
|
|
15214
|
+
await this.notifyNormalExit(instanceName, unclear ? "unclear" : true);
|
|
15215
|
+
}
|
|
15216
|
+
async notifyNormalExit(instanceName, oom = false) {
|
|
15217
|
+
const daemon = oom === "unclear" ? this.daemons.get(instanceName) : undefined;
|
|
15218
|
+
const owner = daemon ? { ...daemon.getInteractionSnapshot().owner } : undefined;
|
|
15219
|
+
const current = daemon && owner ? () => this.daemons.get(instanceName) === daemon
|
|
15220
|
+
&& daemon.isUnclearOomStopped() && sameInteractionOwner(owner, daemon.getInteractionSnapshot().owner) : undefined;
|
|
15221
|
+
if (oom === "unclear" && !current?.())
|
|
15222
|
+
return;
|
|
15223
|
+
const state = oom ? readSessionOomStop(this.getInstanceDir(instanceName)) : null;
|
|
15224
|
+
if (oom === "unclear" && state?.blocked)
|
|
15225
|
+
return;
|
|
15226
|
+
const proof = state?.blocked ? state.record?.evidence ?? state.evidence : undefined;
|
|
15227
|
+
if (oom === true && !proof)
|
|
15228
|
+
return;
|
|
15229
|
+
const sessionOomRetry = oom ? captureSessionOomRetry(this.getInstanceDir(instanceName)) : undefined;
|
|
15230
|
+
if (oom && [...this.pendingNonceButtons.values()].some(p => p.prefix === OOM_RETRY_CALLBACK_PREFIX
|
|
15231
|
+
&& p.instanceName === instanceName && p.sessionOomRetry?.revision === sessionOomRetry?.revision
|
|
15232
|
+
&& (oom !== "unclear" || p.confirmationCurrent?.() === true)))
|
|
15233
|
+
return;
|
|
15234
|
+
const prefix = oom ? OOM_RETRY_CALLBACK_PREFIX : EXIT_RESTART_CALLBACK_PREFIX;
|
|
15235
|
+
const message = oom === "unclear" ? t("inst.oom_unclear_choice", instanceName) : oom && proof
|
|
15236
|
+
? t("inst.session_oom_choice", instanceName, describeSelfOom(proof))
|
|
15237
|
+
: t("exit.general_notice", placeRef({ kind: "instance", name: instanceName }, instanceName));
|
|
15238
|
+
const choices = oom
|
|
15239
|
+
? [{ action: "resume", label: t("inst.session_oom_resume") }, { action: "fresh", label: t("inst.session_oom_fresh") }]
|
|
15240
|
+
: [{ action: "restart", label: t("exit.restart") }, { action: "ignore", label: t("exit.ignore") }];
|
|
15241
|
+
const extra = sessionOomRetry ? { sessionOomRetry, ...(current ? { confirmationCurrent: current } : {}) } : undefined;
|
|
15242
|
+
this.notifyInstanceTopic(instanceName, (oom ? message : t("exit.instance_notice", instanceName)) + placeSuffix({ kind: "general" }, "General"));
|
|
14123
15243
|
const web = this.webOnlyPromptPlace();
|
|
14124
15244
|
if (web) {
|
|
14125
15245
|
await this.postNonceButtonPrompt({
|
|
14126
|
-
prefix
|
|
14127
|
-
message
|
|
14128
|
-
choices
|
|
15246
|
+
prefix, alertType: "exit_restart", instanceName, ...web,
|
|
15247
|
+
message, extra,
|
|
15248
|
+
choices,
|
|
14129
15249
|
expiredText: t("exit.expired", instanceName),
|
|
14130
15250
|
});
|
|
14131
15251
|
return;
|
|
@@ -14146,37 +15266,36 @@ export class FleetManager {
|
|
|
14146
15266
|
return;
|
|
14147
15267
|
}
|
|
14148
15268
|
await this.postNonceButtonPrompt({
|
|
14149
|
-
prefix
|
|
15269
|
+
prefix,
|
|
14150
15270
|
alertType: "exit_restart",
|
|
14151
15271
|
instanceName,
|
|
14152
15272
|
adapter,
|
|
14153
15273
|
adapterId,
|
|
14154
15274
|
chatId,
|
|
14155
15275
|
threadId,
|
|
14156
|
-
message
|
|
14157
|
-
choices
|
|
14158
|
-
{ action: "restart", label: t("exit.restart") },
|
|
14159
|
-
{ action: "ignore", label: t("exit.ignore") },
|
|
14160
|
-
],
|
|
15276
|
+
message, extra,
|
|
15277
|
+
choices,
|
|
14161
15278
|
expiredText: t("exit.expired", instanceName),
|
|
14162
15279
|
});
|
|
14163
15280
|
}
|
|
14164
15281
|
/** Consume a clean-exit Restart/Ignore button exactly once. */
|
|
14165
15282
|
async handleExitRestartPrompt(data, callbackAdapterId, receivingAdapter) {
|
|
14166
|
-
const
|
|
15283
|
+
const oom = data.callbackData.startsWith(OOM_RETRY_CALLBACK_PREFIX);
|
|
15284
|
+
const claimed = this.consumeNonceCallback(oom ? OOM_RETRY_CALLBACK_PREFIX : EXIT_RESTART_CALLBACK_PREFIX, oom ? /^oom-retry:([0-9a-f]+):(resume|fresh)$/ : /^exit-restart:([0-9a-f]+):(restart|ignore)$/, data, callbackAdapterId, receivingAdapter);
|
|
14167
15285
|
if (claimed === null)
|
|
14168
15286
|
return false;
|
|
14169
15287
|
if (claimed === "consumed")
|
|
14170
15288
|
return true;
|
|
14171
15289
|
const { entry: pending, action } = claimed;
|
|
14172
|
-
this.eventLog?.insert(pending.instanceName, "normal_exit_action", { action, userId: data.userId });
|
|
15290
|
+
this.eventLog?.insert(pending.instanceName, oom ? "session_oom_action" : "normal_exit_action", { action, userId: data.userId });
|
|
14173
15291
|
if (action === "ignore") {
|
|
14174
15292
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
|
|
14175
15293
|
return true;
|
|
14176
15294
|
}
|
|
14177
15295
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
|
|
14178
15296
|
try {
|
|
14179
|
-
await this.restartSingleInstance(pending.instanceName
|
|
15297
|
+
await this.restartSingleInstance(pending.instanceName, { explicit: true, ...(oom ? { freshStart: action === "fresh", sessionOomRetry: pending.sessionOomRetry,
|
|
15298
|
+
...(pending.confirmationCurrent ? { recoveryCurrent: pending.confirmationCurrent } : {}) } : {}) });
|
|
14180
15299
|
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
|
|
14181
15300
|
}
|
|
14182
15301
|
catch (err) {
|
|
@@ -14601,7 +15720,7 @@ export class FleetManager {
|
|
|
14601
15720
|
}
|
|
14602
15721
|
}
|
|
14603
15722
|
this.persistCancelButtons();
|
|
14604
|
-
this.logger.
|
|
15723
|
+
this.logger.debug({ instanceName, messageId: sent.messageId }, "Cancel button sent"); // #2482: per-turn bookkeeping
|
|
14605
15724
|
// Retirement is normally edge-triggered, but the edge (or an explicit
|
|
14606
15725
|
// reply/cancel clear) may have happened while notifyAlert was in flight.
|
|
14607
15726
|
// Reconcile the level after publication so a late button cannot resurrect
|
|
@@ -14867,7 +15986,8 @@ export class FleetManager {
|
|
|
14867
15986
|
this.deleteButtonMessage(entry)
|
|
14868
15987
|
.then(() => {
|
|
14869
15988
|
this.discardButton(entry);
|
|
14870
|
-
this.logger.
|
|
15989
|
+
this.logger.debug(// #2482: per-turn bookkeeping
|
|
15990
|
+
{ instanceName: entry.instanceName, messageId: entry.messageId, historyPreserved: Boolean(entry.toolProgress) }, "Cancel button retired");
|
|
14871
15991
|
})
|
|
14872
15992
|
.catch((err) => this.scheduleButtonRetry(entry, err));
|
|
14873
15993
|
}
|
|
@@ -15108,6 +16228,86 @@ export class FleetManager {
|
|
|
15108
16228
|
}
|
|
15109
16229
|
return this.loginController;
|
|
15110
16230
|
}
|
|
16231
|
+
/** #2233: sign-ins started from the web dashboard (local admin sessions), on the same controller as chat's. */
|
|
16232
|
+
webBackendLoginsInstance = null;
|
|
16233
|
+
get webBackendLogins() {
|
|
16234
|
+
this.webBackendLoginsInstance ??= new WebBackendLogins({
|
|
16235
|
+
isLocalAdmin: handle => this.isLocalAdminSession(handle),
|
|
16236
|
+
start: (backend, chat, opts) => this.startWebDashboardLogin(backend, chat, opts),
|
|
16237
|
+
accessToken: sink => this.loginController?.webAccessToken(sink) ?? null,
|
|
16238
|
+
isActiveFor: sink => this.loginController?.isActiveFor(sink) ?? false,
|
|
16239
|
+
cancelFor: sink => this.loginController?.cancelFor(sink) ?? Promise.resolve(false),
|
|
16240
|
+
cancel: () => this.cancelLoginSession(),
|
|
16241
|
+
notice: ({ shown, label, handle }) => this.notifyFleetError(t("login.web_dashboard_notice", shown, label, handle.slice(0, 8))),
|
|
16242
|
+
// No details in the event: each page reads its own view (GET /ui/backend-login), as its session may see it.
|
|
16243
|
+
changed: () => broadcastSseEvent(this.sseClients, "backend_login", {}, err => this.logger.debug({ err }, "backend_login event failed")),
|
|
16244
|
+
});
|
|
16245
|
+
return this.webBackendLoginsInstance;
|
|
16246
|
+
}
|
|
16247
|
+
/** #2233: the login a paused-for-sign-in instance needs, when the dashboard can sign it in. */
|
|
16248
|
+
signInFor(name) {
|
|
16249
|
+
const backend = this.backendNameForInstance(name);
|
|
16250
|
+
const profile = this.credentialProfileOf(name, backend);
|
|
16251
|
+
return dashboardSignInSupported(backend, profile) ? { backend, ...(profile ? { profile } : {}) } : null;
|
|
16252
|
+
}
|
|
16253
|
+
/**
|
|
16254
|
+
* #2233: Settings → Agents' sign-in list — every backend's default login as the availability snapshot has it, then
|
|
16255
|
+
* each credential profile an agent uses (its own login state, read offline).
|
|
16256
|
+
*/
|
|
16257
|
+
backendSignInTargets() {
|
|
16258
|
+
const out = [];
|
|
16259
|
+
for (const row of this.backendAvailabilityRows()) {
|
|
16260
|
+
const flow = LOGIN_FLOWS[row.backend];
|
|
16261
|
+
const install = BACKEND_INSTALLATION_INFO[row.backend]?.install;
|
|
16262
|
+
out.push({
|
|
16263
|
+
// Not installed or too old: signing in would not help — the row says how to install (or update) it instead.
|
|
16264
|
+
backend: row.backend, state: row.state, signIn: row.state !== "not_installed" && row.state !== "too_old" && dashboardSignInSupported(row.backend),
|
|
16265
|
+
...(flow?.remoteLogin === "unsupported" ? { hostCommand: flow.command } : {}),
|
|
16266
|
+
...(row.state === "not_installed" && install ? { install } : {}),
|
|
16267
|
+
...(row.state === "too_old" && install ? { update: install } : {}),
|
|
16268
|
+
});
|
|
16269
|
+
}
|
|
16270
|
+
const seen = new Set();
|
|
16271
|
+
for (const name of Object.keys(this.fleetConfig?.instances ?? {}).sort()) {
|
|
16272
|
+
const backend = this.backendNameForInstance(name);
|
|
16273
|
+
const profile = this.credentialProfileOf(name, backend);
|
|
16274
|
+
if (!profile || seen.has(`${backend}\0${profile}`))
|
|
16275
|
+
continue;
|
|
16276
|
+
seen.add(`${backend}\0${profile}`);
|
|
16277
|
+
let login = "unknown";
|
|
16278
|
+
try {
|
|
16279
|
+
login = credentialProfileLogin(this.dataDir, backend, profile).state;
|
|
16280
|
+
}
|
|
16281
|
+
catch { /* unknown */ }
|
|
16282
|
+
const signIn = dashboardSignInSupported(backend, profile);
|
|
16283
|
+
const hostCommand = signIn ? null : credentialProfileLoginCommand(this.dataDir, backend, profile);
|
|
16284
|
+
out.push({ backend, profile, state: login === "logged-in" ? "usable" : login === "signed-out" ? "not_signed_in" : "unknown", signIn,
|
|
16285
|
+
...(hostCommand ? { hostCommand } : {}) });
|
|
16286
|
+
}
|
|
16287
|
+
return out;
|
|
16288
|
+
}
|
|
16289
|
+
/** #2233: the session with this handle is live, admin, local and of the current dashboard token. */
|
|
16290
|
+
isLocalAdminSession(handle) {
|
|
16291
|
+
const sessions = this.webSessions, token = this.webToken;
|
|
16292
|
+
const record = sessions?.findByHandle(handle);
|
|
16293
|
+
return !!record && !!token && record.tier === "admin" && record.surface === "local" && sessions.isCurrent(record, tokenEpoch(token));
|
|
16294
|
+
}
|
|
16295
|
+
/**
|
|
16296
|
+
* #2233: `/login` for a dashboard requester. The same controller and checks; a CLI that is not installed is refused
|
|
16297
|
+
* with its install command (chat's install-first runs a terminal of its own, which the dashboard does not offer).
|
|
16298
|
+
*/
|
|
16299
|
+
startWebDashboardLogin(backendArg, chat, opts) {
|
|
16300
|
+
const wanted = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
16301
|
+
if (isRemovedBackend(wanted))
|
|
16302
|
+
return Promise.resolve(removedBackendMessage(wanted));
|
|
16303
|
+
const info = BACKEND_INSTALLATION_INFO[wanted];
|
|
16304
|
+
if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !this.isCliInstalled(wanted)) {
|
|
16305
|
+
return Promise.resolve(t("login.web_dashboard_not_installed", wanted, info.install));
|
|
16306
|
+
}
|
|
16307
|
+
if (!opts.skipAuthCheck)
|
|
16308
|
+
this.recordLoginFlow(wanted, "login", chat.userId);
|
|
16309
|
+
return this.webLogin.start(backendArg, chat, opts);
|
|
16310
|
+
}
|
|
15111
16311
|
/**
|
|
15112
16312
|
* Post the backend chooser for a bare `/login`. Caller enforces admin.
|
|
15113
16313
|
* Returns why the chooser could not be posted, for the caller to tell the
|
|
@@ -15750,6 +16950,36 @@ export class FleetManager {
|
|
|
15750
16950
|
const failure = await this.promptLoginBackends(chat);
|
|
15751
16951
|
await data.respond(failure ?? t("login.chooser_posted"));
|
|
15752
16952
|
}
|
|
16953
|
+
/** #2293: whose cross-instance messages search leaves out now (web-api /ui/search). */
|
|
16954
|
+
searchVisibility() { return xmsgVisibility(this.fleetConfig); }
|
|
16955
|
+
/** #2293: a cross-instance message for the search index (outbound-handlers, after it was admitted). */
|
|
16956
|
+
indexCrossInstance(record) {
|
|
16957
|
+
this.searchIndex?.record(xmsgDoc(record));
|
|
16958
|
+
}
|
|
16959
|
+
/**
|
|
16960
|
+
* #2293: the global search index, at fleet start — after the web chat is restored (the backfill reads it). Its worker
|
|
16961
|
+
* owns search.db; see search-index.ts. Off with web.search.enabled: false.
|
|
16962
|
+
*/
|
|
16963
|
+
startSearchIndex(fleet) {
|
|
16964
|
+
const config = fleet.web?.search;
|
|
16965
|
+
if (config?.enabled === false || this.searchIndex)
|
|
16966
|
+
return;
|
|
16967
|
+
const history = this.webChatHistory;
|
|
16968
|
+
this.searchIndex = new SearchIndex({
|
|
16969
|
+
path: join(this.dataDir, "search.db"),
|
|
16970
|
+
retentionDays: config?.retention_days ?? SEARCH_RETENTION_DAYS,
|
|
16971
|
+
outboxPath: join(this.dataDir, "delivery-outbox.db"),
|
|
16972
|
+
schedulerPath: join(this.dataDir, "scheduler.db"),
|
|
16973
|
+
chatDocs: function* () { for (const name of history.instances())
|
|
16974
|
+
for (const m of history.list(name)) {
|
|
16975
|
+
const doc = chatDoc(m);
|
|
16976
|
+
if (doc)
|
|
16977
|
+
yield doc;
|
|
16978
|
+
} },
|
|
16979
|
+
log: (level, msg) => this.logger[level](msg),
|
|
16980
|
+
});
|
|
16981
|
+
this.searchIndex.start();
|
|
16982
|
+
}
|
|
15753
16983
|
queueMirrorMessage(text) {
|
|
15754
16984
|
const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
|
|
15755
16985
|
if (mirrorTopicId == null || !this.adapter)
|
|
@@ -15844,8 +17074,15 @@ export class FleetManager {
|
|
|
15844
17074
|
links: m.links,
|
|
15845
17075
|
channel: m.channel,
|
|
15846
17076
|
from: m.from,
|
|
17077
|
+
replyTo: m.replyTo,
|
|
17078
|
+
cards: m.cards,
|
|
17079
|
+
posts: m.posts, // #2281: kept beside the row, never broadcast (not a row field)
|
|
17080
|
+
reserved: m.reserved, // #1774: an agent reply's place, taken when it was sent
|
|
15847
17081
|
});
|
|
15848
|
-
|
|
17082
|
+
// A sending page's own id for its pending bubble rides on the live copy only: never recorded, never replayed.
|
|
17083
|
+
const clientId = typeof data.clientId === "string" ? data.clientId : "";
|
|
17084
|
+
broadcastSseEvent(this.sseClients, event, clientId ? { ...recorded, clientId } : recorded, onError, this.webChatHistory.cursorOf(recorded));
|
|
17085
|
+
this.searchIndex?.record(chatDoc(recorded));
|
|
15849
17086
|
// #1629: live-only receipt after the recorded agent reply. Never persist/replay it or give it an SSE cursor.
|
|
15850
17087
|
if (recorded.role === "agent")
|
|
15851
17088
|
broadcastSseEvent(this.sseClients, "agent_reply_live", { v: 1, boot: recorded.boot, id: recorded.id, instance: recorded.instance }, onError);
|
|
@@ -15858,6 +17095,57 @@ export class FleetManager {
|
|
|
15858
17095
|
}
|
|
15859
17096
|
broadcastSseEvent(this.sseClients, event, data, onError);
|
|
15860
17097
|
}
|
|
17098
|
+
/**
|
|
17099
|
+
* The task board's live side: every committed change goes to the open pages (never to a guest) — the board's new
|
|
17100
|
+
* revision and the card numbers it touched; not who touched them (the page does not need it, and a public-link page
|
|
17101
|
+
* must not learn a hidden instance's name from it). Its news goes to the instances whose cards someone else changed:
|
|
17102
|
+
* one "[Task board]" notice per instance, gathered for 10 s, held by the delivery outbox until it is taken
|
|
17103
|
+
* (task-board-waker.ts).
|
|
17104
|
+
*/
|
|
17105
|
+
attachTaskBoard(db) {
|
|
17106
|
+
this.taskBoardWaker?.stop();
|
|
17107
|
+
// #2303: each instance's cards at once, read at every claim (a fleet.yaml edit applies to the next one).
|
|
17108
|
+
db.board.setCardLimit((name) => boardCardLimit(this.fleetConfig, name));
|
|
17109
|
+
const waker = this.taskBoardWaker = new TaskBoardWaker({
|
|
17110
|
+
deliver: (instance, message, key) => this.admitTaskBoardNotice(instance, message, key),
|
|
17111
|
+
isInstance: (name) => this.fleetConfig?.instances?.[name] != null,
|
|
17112
|
+
holds: (wake) => db.board.wakeHolds(wake),
|
|
17113
|
+
card: (uuid) => db.board.findCard(uuid),
|
|
17114
|
+
// #2352: the card's Needs you buttons are in this instance's own channel (it is the card's lane).
|
|
17115
|
+
buttonsHere: (instance, uuid) => {
|
|
17116
|
+
// #2355 review: no live message, no buttons — the old words then.
|
|
17117
|
+
if (this.fleetConfig?.needs_you?.live_message === false)
|
|
17118
|
+
return false;
|
|
17119
|
+
const card = db.board.findCard(uuid);
|
|
17120
|
+
// #2434: only while it waits for the person — a card that does not is in no Needs you, so no buttons anywhere.
|
|
17121
|
+
if (!card || !waitsForPerson(card, this.dispatcherGone))
|
|
17122
|
+
return false;
|
|
17123
|
+
const leads = new Map(db.board.listProjects(true).map((p) => [p.id, p.leadInstance]));
|
|
17124
|
+
return this.needsYouLane(card, leads) === instance;
|
|
17125
|
+
},
|
|
17126
|
+
log: (level, message, extra) => this.logger[level](extra ?? {}, message),
|
|
17127
|
+
});
|
|
17128
|
+
db.board.onChange(({ rev, ids, projects, wakes }) => {
|
|
17129
|
+
// Refresh both old and new owners on reassignment; wakes are not an ownership index.
|
|
17130
|
+
if (ids.length)
|
|
17131
|
+
for (const name of this.daemons.keys())
|
|
17132
|
+
this.observeWorkingState(name, "tasks");
|
|
17133
|
+
this.emitSseEvent("tasks", { rev, ids, projects });
|
|
17134
|
+
waker.note(wakes);
|
|
17135
|
+
// step 3: cards waiting for the person are Needs you items; #2355 review: a project's lead routes them too.
|
|
17136
|
+
if (ids.length || projects)
|
|
17137
|
+
this.needsYou?.poke();
|
|
17138
|
+
});
|
|
17139
|
+
}
|
|
17140
|
+
/** A task board notice, as AgEnD's own (not a person's words): through the delivery outbox, held until delivered. */
|
|
17141
|
+
admitTaskBoardNotice(instance, message, key) {
|
|
17142
|
+
this.ensureDeliveryOutbox();
|
|
17143
|
+
const outbox = this.deliveryOutbox;
|
|
17144
|
+
if (!outbox)
|
|
17145
|
+
return false;
|
|
17146
|
+
outbox.admitSystemNotice(instance, key, message, new Date());
|
|
17147
|
+
return true;
|
|
17148
|
+
}
|
|
15861
17149
|
listClaimedTasks(assignee) {
|
|
15862
17150
|
try {
|
|
15863
17151
|
return this.scheduler?.db.listTasks({ assignee, status: "claimed" }) ?? [];
|
|
@@ -15940,7 +17228,7 @@ export class FleetManager {
|
|
|
15940
17228
|
}
|
|
15941
17229
|
await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
|
|
15942
17230
|
try {
|
|
15943
|
-
await this.restartSingleInstance(pending.instanceName);
|
|
17231
|
+
await this.restartSingleInstance(pending.instanceName, { explicit: true });
|
|
15944
17232
|
await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
|
|
15945
17233
|
}
|
|
15946
17234
|
catch (err) {
|
|
@@ -16060,6 +17348,27 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16060
17348
|
this.syncRoleSkills(workDir, backend, "general", knowledgeDir);
|
|
16061
17349
|
this.logger.debug({ knowledgeDir, workDir, backend }, "Synced general knowledge files");
|
|
16062
17350
|
}
|
|
17351
|
+
/** Update only AgEnD-managed skill copies; no launch or restart is needed for an experimental switch. */
|
|
17352
|
+
canvasWorkerSettingChanged() {
|
|
17353
|
+
this.refreshExperimentalSkills();
|
|
17354
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
17355
|
+
}
|
|
17356
|
+
refreshExperimentalSkills() {
|
|
17357
|
+
for (const [name, daemon] of this.daemons) {
|
|
17358
|
+
try {
|
|
17359
|
+
const cfg = daemon.getConfigSnapshot();
|
|
17360
|
+
const backend = cfg.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
17361
|
+
const workDir = this.resolveKnowledgeWorkDir(cfg.working_directory, backend, name);
|
|
17362
|
+
const classic = this.classicChannels?.getAll().some(ch => ch.instanceName === name) === true;
|
|
17363
|
+
if (classic && !insideManagedWorkspaces(workDir))
|
|
17364
|
+
continue;
|
|
17365
|
+
this.syncRoleSkills(workDir, backend, classic ? "classic" : cfg.general_topic ? "general" : "worker");
|
|
17366
|
+
}
|
|
17367
|
+
catch (err) {
|
|
17368
|
+
this.logger.warn({ name, err }, "Could not refresh experimental skill visibility");
|
|
17369
|
+
}
|
|
17370
|
+
}
|
|
17371
|
+
}
|
|
16063
17372
|
/** Publish only the bundled skills eligible for an instance role. */
|
|
16064
17373
|
syncRoleSkills(workDir, backend, role, knowledgeDir) {
|
|
16065
17374
|
const skillSegments = FleetManager.SKILLS_DIR_SEGMENTS[backend];
|
|
@@ -16073,7 +17382,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16073
17382
|
// can keep those copies current; other backends never had unmanaged
|
|
16074
17383
|
// AgEnD-published skills and must retain the normal user-ownership guard.
|
|
16075
17384
|
const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
|
|
16076
|
-
this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged
|
|
17385
|
+
this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged,
|
|
17386
|
+
// #2163: a Classic bot's skills folder (or a skill in it) may be a link out of its workspace; every path is
|
|
17387
|
+
// checked where it really lands, just before it is written.
|
|
17388
|
+
role === "classic" ? (path) => insideManagedWorkspaces(path) : undefined);
|
|
16077
17389
|
this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
|
|
16078
17390
|
}
|
|
16079
17391
|
/**
|
|
@@ -16159,11 +17471,63 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16159
17471
|
* migration exception is the pre-manifest Kiro General layout explicitly
|
|
16160
17472
|
* selected by adoptLegacyUnmanaged.
|
|
16161
17473
|
*/
|
|
16162
|
-
|
|
17474
|
+
/**
|
|
17475
|
+
* #2482: the user's own skills that shadow bundled ones, said once per directory and change — not once per name on
|
|
17476
|
+
* every start (11 lines a start on the host). The record (`skipped-skills.json` in the AgEnD home) keeps what was said
|
|
17477
|
+
* across restarts; a new name or an edited SKILL.md is said again.
|
|
17478
|
+
*/
|
|
17479
|
+
reportSkippedSkills(destSkills, names) {
|
|
17480
|
+
const signature = names.map(name => {
|
|
17481
|
+
let content = "";
|
|
17482
|
+
try {
|
|
17483
|
+
content = readFileSync(join(destSkills, name, "SKILL.md"), "utf-8");
|
|
17484
|
+
}
|
|
17485
|
+
catch { /* gone: its name alone */ }
|
|
17486
|
+
return `${name}:${createHash("sha256").update(content).digest("hex").slice(0, 16)}`;
|
|
17487
|
+
}).join(",");
|
|
17488
|
+
if (this.skippedSkillsSaid.get(destSkills) === signature)
|
|
17489
|
+
return;
|
|
17490
|
+
this.skippedSkillsSaid.set(destSkills, signature);
|
|
17491
|
+
const path = join(this.dataDir, "skipped-skills.json");
|
|
17492
|
+
// Only a log's memory: whatever is in the file (missing, corrupt, any other shape) never stands in the sync's way
|
|
17493
|
+
// (#2493 review) — a record that cannot be read is no record, and the warning is said.
|
|
17494
|
+
const record = Object.create(null);
|
|
17495
|
+
try {
|
|
17496
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
17497
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
17498
|
+
for (const [key, value] of Object.entries(parsed))
|
|
17499
|
+
if (typeof value === "string")
|
|
17500
|
+
record[key] = value;
|
|
17501
|
+
}
|
|
17502
|
+
}
|
|
17503
|
+
catch { /* none yet, or unreadable */ }
|
|
17504
|
+
if (Object.hasOwn(record, destSkills) && record[destSkills] === signature) {
|
|
17505
|
+
this.logger.debug({ skills: names, destSkills }, "Skipping bundled skills — unchanged since it was last said");
|
|
17506
|
+
return;
|
|
17507
|
+
}
|
|
17508
|
+
record[destSkills] = signature;
|
|
17509
|
+
try {
|
|
17510
|
+
writeFileSync(path, JSON.stringify(record) + "\n");
|
|
17511
|
+
}
|
|
17512
|
+
catch { /* said this time; said again next start */ }
|
|
17513
|
+
this.logger.warn({ skills: names, destSkills }, "Skipping bundled skills — skills of these names exist but were not published by AgEnD");
|
|
17514
|
+
}
|
|
17515
|
+
syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false, confined) {
|
|
16163
17516
|
if (!existsSync(srcSkills))
|
|
16164
17517
|
return;
|
|
17518
|
+
const refuse = (path) => {
|
|
17519
|
+
if (!confined || confined(path))
|
|
17520
|
+
return false;
|
|
17521
|
+
this.logger.warn({ path, destSkills }, "Not publishing skills — the path leads out of the managed workspace");
|
|
17522
|
+
return true;
|
|
17523
|
+
};
|
|
17524
|
+
if (refuse(destSkills))
|
|
17525
|
+
return;
|
|
16165
17526
|
mkdirSync(destSkills, { recursive: true });
|
|
16166
17527
|
const manifestPath = join(destSkills, ".agend-managed-skills.json");
|
|
17528
|
+
// Its ownership claims decide what is replaced or removed below: one that lives elsewhere is never believed.
|
|
17529
|
+
if (refuse(manifestPath))
|
|
17530
|
+
return;
|
|
16167
17531
|
const hadManifest = existsSync(manifestPath);
|
|
16168
17532
|
let previouslyManaged = [];
|
|
16169
17533
|
try {
|
|
@@ -16176,41 +17540,42 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16176
17540
|
.filter(name => existsSync(join(srcSkills, name, "SKILL.md")))
|
|
16177
17541
|
.sort();
|
|
16178
17542
|
// Pre-manifest Kiro General copied bundled skills directly into
|
|
16179
|
-
// .kiro/skills. If every existing skill is still
|
|
16180
|
-
// the
|
|
16181
|
-
// the ownership manifest. Any extra skill name keeps the directory fully
|
|
17543
|
+
// .kiro/skills. If every existing skill is still bundled AND belongs to
|
|
17544
|
+
// the closed pre-manifest cohort, adopt it once, update it below, and
|
|
17545
|
+
// write the ownership manifest. Any extra skill name keeps the directory fully
|
|
16182
17546
|
// on the user-owned/collision path.
|
|
16183
17547
|
if (!hadManifest && adoptLegacyUnmanaged) {
|
|
16184
17548
|
const existing = readdirSync(destSkills, { withFileTypes: true })
|
|
16185
17549
|
.filter(entry => entry.isDirectory() && existsSync(join(destSkills, entry.name, "SKILL.md")))
|
|
16186
17550
|
.map(entry => entry.name)
|
|
16187
17551
|
.sort();
|
|
16188
|
-
if (existing.length > 0 && existing.every(name => bundled.includes(name))) {
|
|
17552
|
+
if (existing.length > 0 && existing.every(name => bundled.includes(name) && PRE_MANIFEST_KIRO_SKILLS.has(name))) {
|
|
16189
17553
|
previouslyManaged = existing;
|
|
16190
17554
|
this.logger.info({ skills: existing, destSkills }, "Adopted legacy AgEnD skills into managed ownership");
|
|
16191
17555
|
}
|
|
16192
17556
|
}
|
|
16193
17557
|
const eligible = bundled.filter(name => {
|
|
16194
17558
|
const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
|
|
16195
|
-
// General is the coordinator and receives
|
|
16196
|
-
//
|
|
16197
|
-
return role === "general" || roles.includes(
|
|
17559
|
+
// General is the coordinator and receives every playbook. Workers and Classic bots receive only skills
|
|
17560
|
+
// explicitly marked for their role (#2163).
|
|
17561
|
+
return role === "general" || roles.includes(role);
|
|
16198
17562
|
});
|
|
16199
17563
|
const managed = [];
|
|
17564
|
+
const skipped = [];
|
|
16200
17565
|
for (const name of eligible) {
|
|
16201
17566
|
const destDir = join(destSkills, name);
|
|
16202
17567
|
const dest = join(destDir, "SKILL.md");
|
|
16203
17568
|
const isOurs = previouslyManaged.includes(name) || !existsSync(destDir);
|
|
16204
17569
|
if (!isOurs) {
|
|
16205
|
-
// The user's skill still wins on every sync; only the warning is deduped
|
|
16206
|
-
|
|
16207
|
-
|
|
16208
|
-
this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
|
|
16209
|
-
}
|
|
17570
|
+
// The user's skill still wins on every sync; only the warning is deduped (#2482: one line per directory, and
|
|
17571
|
+
// only when what is skipped there changed since it was last said — across restarts too).
|
|
17572
|
+
skipped.push(name);
|
|
16210
17573
|
continue;
|
|
16211
17574
|
}
|
|
16212
17575
|
managed.push(name);
|
|
16213
|
-
const newContent =
|
|
17576
|
+
const newContent = bundledSkillContents(join(srcSkills, name), this.canvasWorkerEnabled());
|
|
17577
|
+
if (refuse(dest))
|
|
17578
|
+
continue;
|
|
16214
17579
|
try {
|
|
16215
17580
|
if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
|
|
16216
17581
|
continue;
|
|
@@ -16219,6 +17584,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16219
17584
|
mkdirSync(destDir, { recursive: true });
|
|
16220
17585
|
writeFileSync(dest, newContent);
|
|
16221
17586
|
}
|
|
17587
|
+
if (skipped.length)
|
|
17588
|
+
this.reportSkippedSkills(destSkills, skipped);
|
|
16222
17589
|
// Remove managed skills that are no longer bundled OR no longer eligible
|
|
16223
17590
|
// for this role. This makes a shared → general-only metadata change take
|
|
16224
17591
|
// effect on the next worker startup instead of leaving stale capability.
|
|
@@ -16257,9 +17624,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16257
17624
|
if (!Array.isArray(frontmatter.roles))
|
|
16258
17625
|
throw new Error("roles must be an array");
|
|
16259
17626
|
const roles = [...new Set(frontmatter.roles)]
|
|
16260
|
-
.filter((value) => value === "general" || value === "worker");
|
|
17627
|
+
.filter((value) => value === "general" || value === "worker" || value === "classic");
|
|
16261
17628
|
if (roles.length !== frontmatter.roles.length || roles.length === 0) {
|
|
16262
|
-
throw new Error("roles must contain only general and/or
|
|
17629
|
+
throw new Error("roles must contain only general, worker and/or classic");
|
|
16263
17630
|
}
|
|
16264
17631
|
return roles;
|
|
16265
17632
|
}
|
|
@@ -16550,7 +17917,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16550
17917
|
try {
|
|
16551
17918
|
const tmpPath = await dlAdapter.downloadAttachment(att.fileId);
|
|
16552
17919
|
const inboxDir = join(getAgendHome(), "workspaces", instanceName, "inbox");
|
|
16553
|
-
mkdirSync(inboxDir, { recursive: true });
|
|
17920
|
+
mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
|
|
16554
17921
|
const dest = join(inboxDir, basename(tmpPath));
|
|
16555
17922
|
// Copy to destination — failure means this attachment is skipped
|
|
16556
17923
|
try {
|
|
@@ -16633,7 +18000,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16633
18000
|
content: fullText,
|
|
16634
18001
|
targetSession: instanceName,
|
|
16635
18002
|
meta,
|
|
16636
|
-
}, msg.humanOrigin, msg.humanOriginalBody ?? text);
|
|
18003
|
+
}, msg.humanOrigin, msg.humanOriginalBody ?? text, this.startRetryHoldFor(instanceName));
|
|
16637
18004
|
reactions.consume();
|
|
16638
18005
|
}
|
|
16639
18006
|
catch (err) {
|
|
@@ -16962,7 +18329,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16962
18329
|
}
|
|
16963
18330
|
}
|
|
16964
18331
|
const path = this.cliEnvPath(backend, profile);
|
|
16965
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
18332
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
16966
18333
|
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
16967
18334
|
return env;
|
|
16968
18335
|
}
|
|
@@ -18591,7 +19958,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18591
19958
|
// also sees the channel's messages.
|
|
18592
19959
|
if (adapterId)
|
|
18593
19960
|
this.bindInstanceAdapter(instanceName, adapterId);
|
|
18594
|
-
|
|
19961
|
+
if (this.sessionOomStopped(instanceName)) {
|
|
19962
|
+
await this.restartSingleInstance(instanceName, { explicit: true });
|
|
19963
|
+
}
|
|
19964
|
+
else {
|
|
19965
|
+
await this.startClassicInstance(instanceName, classicChannels.getBackend(channelId, adapterId, this.fleetConfig?.defaults?.backend), classicChannels.getPreTaskCommand(channelId, adapterId), classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after));
|
|
19966
|
+
}
|
|
18595
19967
|
this.reregisterClassicChannels();
|
|
18596
19968
|
// Auto-enable collab for Discord classic channels (TG uses @mention directly without collab mode)
|
|
18597
19969
|
if (guildId && !classicChannels.isCollab(channelId, adapterId)) {
|
|
@@ -18648,23 +20020,56 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18648
20020
|
* settles, so a later genuine stop (after a restart) still does the work.
|
|
18649
20021
|
*/
|
|
18650
20022
|
stopAll() {
|
|
18651
|
-
this.stopAllInFlight ??= this.doStopAll().finally(() => {
|
|
20023
|
+
this.stopAllInFlight ??= this.doStopAll().finally(() => {
|
|
20024
|
+
// #1956: after the stop's own tmux work; anything later goes direct. A later startAll starts it again.
|
|
20025
|
+
if (this.tmuxExecHelperTimer) {
|
|
20026
|
+
clearInterval(this.tmuxExecHelperTimer);
|
|
20027
|
+
this.tmuxExecHelperTimer = null;
|
|
20028
|
+
}
|
|
20029
|
+
// #2364: and the stop waits (bounded) for the helper to leave on its own, before the caller exits the process.
|
|
20030
|
+
return tmuxExecHelper.stop();
|
|
20031
|
+
}).finally(() => { this.stopAllInFlight = null; });
|
|
18652
20032
|
return this.stopAllInFlight;
|
|
18653
20033
|
}
|
|
20034
|
+
tmuxExecHelperTimer = null;
|
|
18654
20035
|
stopAllInFlight = null;
|
|
20036
|
+
/** Stops release memory. Reassess between batches without waiting for recovery. */
|
|
20037
|
+
async stopInstancesInBatches(entries, stop) {
|
|
20038
|
+
let index = 0;
|
|
20039
|
+
while (index < entries.length) {
|
|
20040
|
+
// Linux procfs can still be read after the periodic sampler is stopped.
|
|
20041
|
+
// Do not revive a stopped Darwin probe/worker just to size a quit batch;
|
|
20042
|
+
// its full shutdown uses the conservative batch size instead.
|
|
20043
|
+
const level = this.memoryPressure.platform === "darwin"
|
|
20044
|
+
? this.shuttingDown ? "elevated" : this.memoryPressure.snapshot().level
|
|
20045
|
+
: this.memoryPressure.sample().level;
|
|
20046
|
+
const size = fleetStopConcurrency(entries.length, level);
|
|
20047
|
+
const batch = entries.slice(index, index + size);
|
|
20048
|
+
index += batch.length;
|
|
20049
|
+
await Promise.allSettled(batch.map(stop));
|
|
20050
|
+
}
|
|
20051
|
+
}
|
|
18655
20052
|
async doStopAll() {
|
|
20053
|
+
this.startupMemoryDeadline?.stop();
|
|
20054
|
+
this.startupMemoryDeadline = null;
|
|
18656
20055
|
this.startupComplete = false;
|
|
20056
|
+
this.startupDashboard = null;
|
|
18657
20057
|
this.reloadPending = false;
|
|
18658
20058
|
// Before anything is stopped: everything that dies from here on dies
|
|
18659
20059
|
// because we asked it to. Set synchronously — doStopAll runs to its first
|
|
18660
20060
|
// await in the same tick as the signal handler, so no event can slip in.
|
|
18661
20061
|
this.shuttingDown = true;
|
|
20062
|
+
if (this.updateProgressTimer) {
|
|
20063
|
+
clearInterval(this.updateProgressTimer);
|
|
20064
|
+
this.updateProgressTimer = null;
|
|
20065
|
+
}
|
|
18662
20066
|
this.settingsGeneration++;
|
|
18663
20067
|
this.settingsConfirmation?.store.close();
|
|
18664
20068
|
this.settingsConfirmation = null;
|
|
18665
20069
|
const settingsControl = this.settingsControl;
|
|
18666
20070
|
this.settingsControl = null;
|
|
18667
20071
|
const settingsControlStopped = settingsControl?.close();
|
|
20072
|
+
this.publicLinkAutoOpen?.stop();
|
|
18668
20073
|
const publicStopped = this.publicWebLink?.close("fleet shutdown");
|
|
18669
20074
|
// #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
|
|
18670
20075
|
// adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
|
|
@@ -18722,6 +20127,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18722
20127
|
this.clearStatuslineWatchers();
|
|
18723
20128
|
this.costGuard?.stop();
|
|
18724
20129
|
this.dailySummary?.stop();
|
|
20130
|
+
this.taskBoardSummary?.stop();
|
|
18725
20131
|
this.dailyTipScheduler?.stop();
|
|
18726
20132
|
this.dailyTipScheduler = null;
|
|
18727
20133
|
if (this.updateCheckTimer) {
|
|
@@ -18802,27 +20208,29 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18802
20208
|
// this is the last moment the prompts can be collapsed.
|
|
18803
20209
|
await this.retirePendingNoncePrompts();
|
|
18804
20210
|
this.topicArchiver.stop();
|
|
20211
|
+
this.taskBoardWaker?.stop();
|
|
20212
|
+
this.appIconStore?.close();
|
|
18805
20213
|
this.scheduler?.shutdown();
|
|
20214
|
+
// What it was sent is written first (bounded: search-index.ts close()).
|
|
20215
|
+
const search = this.searchIndex;
|
|
20216
|
+
this.searchIndex = null;
|
|
20217
|
+
await search?.close();
|
|
18806
20218
|
// Stop instances in parallel batches to avoid long sequential waits.
|
|
18807
20219
|
// Concurrency scales with fleet size — larger fleets tolerate more parallel
|
|
18808
20220
|
// tmux ops, while small fleets stay conservative to avoid overwhelming the
|
|
18809
20221
|
// tmux server.
|
|
18810
20222
|
const entries = [...this.daemons.entries()];
|
|
18811
|
-
const STOP_CONCURRENCY = entries.length > 30 ? 15 : entries.length >= 10 ? 10 : 5;
|
|
18812
20223
|
for (const [name] of entries)
|
|
18813
20224
|
this.ipcStoppingInstances.add(name);
|
|
18814
|
-
|
|
18815
|
-
|
|
18816
|
-
|
|
18817
|
-
|
|
18818
|
-
|
|
18819
|
-
}
|
|
18820
|
-
|
|
18821
|
-
|
|
18822
|
-
|
|
18823
|
-
this.daemons.delete(name);
|
|
18824
|
-
}));
|
|
18825
|
-
}
|
|
20225
|
+
await this.stopInstancesInBatches(entries, async ([name, daemon]) => {
|
|
20226
|
+
try {
|
|
20227
|
+
await daemon.stop();
|
|
20228
|
+
}
|
|
20229
|
+
catch (err) {
|
|
20230
|
+
this.logger.warn({ name, err }, "Stop failed");
|
|
20231
|
+
}
|
|
20232
|
+
this.daemons.delete(name);
|
|
20233
|
+
});
|
|
18826
20234
|
// Close IPC clients in parallel — serial close over a large fleet adds
|
|
18827
20235
|
// noticeable latency.
|
|
18828
20236
|
await Promise.all([...this.instanceIpcClients.values()].map(ipc => Promise.resolve(ipc.close()).catch(() => { })));
|
|
@@ -19033,6 +20441,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19033
20441
|
this.rawFleetConfig = previousRawConfig;
|
|
19034
20442
|
this.rawFleetDocument = previousRawDocument;
|
|
19035
20443
|
this.savedFleetConfigSnapshot = previousSavedSnapshot;
|
|
20444
|
+
this.observeWebAuthority(); // #2453 review: the configuration back in force is the one seen
|
|
19036
20445
|
this.logger.error({
|
|
19037
20446
|
oldCount,
|
|
19038
20447
|
newCount,
|
|
@@ -19052,6 +20461,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19052
20461
|
// mark every row done and tell the user "changes applied".
|
|
19053
20462
|
return { rejected: why };
|
|
19054
20463
|
}
|
|
20464
|
+
if (canvasWorkerEnabled(oldConfig?.web) !== this.canvasWorkerEnabled()) {
|
|
20465
|
+
this.canvasWorkerSettingChanged();
|
|
20466
|
+
}
|
|
19055
20467
|
// Classic behavior settings share the fleet defaults but are not entries
|
|
19056
20468
|
// in fleet.yaml. Snapshot the old effective chain before reloading the
|
|
19057
20469
|
// Classic file so SIGHUP can hot-apply either source without waiting for
|
|
@@ -19128,8 +20540,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19128
20540
|
// so an old/new diff alone can miss the live daemon's stale value.
|
|
19129
20541
|
for (const [name, config] of Object.entries(newInstances)) {
|
|
19130
20542
|
if (!this.daemons.has(name)) {
|
|
19131
|
-
// New instance — startInstance already calls connectIpcToInstance
|
|
19132
|
-
|
|
20543
|
+
// New instance — startInstance already calls connectIpcToInstance. #2482: a paused one is not started (startInstance
|
|
20544
|
+
// returns at its pause); say so, quietly, instead of "starting" on every reload.
|
|
20545
|
+
if (this.lifecycle.isPaused(name))
|
|
20546
|
+
this.logger.debug({ name }, "Paused instance in config — not started");
|
|
20547
|
+
else
|
|
20548
|
+
this.logger.info({ name }, "New instance in config — starting");
|
|
19133
20549
|
observe?.(name, "restart", "running");
|
|
19134
20550
|
await this.startInstanceUnattended(name, config, topicMode, "new instance");
|
|
19135
20551
|
observe?.(name, "restart", "done");
|
|
@@ -19138,6 +20554,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19138
20554
|
const daemon = this.daemons.get(name);
|
|
19139
20555
|
const runtimeConfig = daemon.getConfigSnapshot?.() ?? oldConfig.instances[name];
|
|
19140
20556
|
const change = classifyInstanceChange(runtimeConfig, config);
|
|
20557
|
+
// (b) The person switched its task board on: tell it (its tool list shows the `task` tool at once).
|
|
20558
|
+
if (taskBoardSwitchedOn(runtimeConfig.dashboard, config.dashboard)) {
|
|
20559
|
+
this.admitTaskBoardNotice(name, TASK_BOARD_ON_NOTICE, `task-board-on:${name}:${Date.now()}`);
|
|
20560
|
+
}
|
|
19141
20561
|
if (change === "restart") {
|
|
19142
20562
|
this.logger.info({ name }, "Instance config changed — restarting");
|
|
19143
20563
|
observe?.(name, "restart", "running");
|
|
@@ -19301,6 +20721,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19301
20721
|
return false;
|
|
19302
20722
|
}
|
|
19303
20723
|
/** Non-null when startup found the running config and fleet.yaml disagreeing. */
|
|
20724
|
+
/**
|
|
20725
|
+
* #2346 (settings schema): whether a connection added now would start at once — what canHotAddConnections would decide
|
|
20726
|
+
* for the current config with one more connection appended: another one is running, and nothing else this process took
|
|
20727
|
+
* at startup has changed since (a pending locale or access change, say, needs AgEnD restarted anyway).
|
|
20728
|
+
*/
|
|
20729
|
+
connectionsStartLive() {
|
|
20730
|
+
if (!this.fleetConfig)
|
|
20731
|
+
return false;
|
|
20732
|
+
const current = this.fleetConfig.channels ?? (this.fleetConfig.channel ? [this.fleetConfig.channel] : []);
|
|
20733
|
+
const probe = { id: "\u0000probe", type: "telegram", mode: "topic", bot_token_env: "\u0000PROBE" };
|
|
20734
|
+
const next = { ...this.fleetConfig, channels: [...current, probe] };
|
|
20735
|
+
delete next.channel;
|
|
20736
|
+
return this.canHotAddConnections(next);
|
|
20737
|
+
}
|
|
19304
20738
|
fleetSignatureMismatchKeys() {
|
|
19305
20739
|
return this.fleetSignatureMismatch;
|
|
19306
20740
|
}
|
|
@@ -19519,12 +20953,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19519
20953
|
return this.fleetConfig?.web?.provider_secrets === true;
|
|
19520
20954
|
}
|
|
19521
20955
|
listProviderSecrets() {
|
|
19522
|
-
return PROVIDER_SECRET_SPECS.map(spec => ({
|
|
20956
|
+
return [...PROVIDER_SECRET_SPECS, PUBLIC_TUNNEL_SECRET_SPEC].map(spec => ({
|
|
19523
20957
|
id: spec.id,
|
|
19524
20958
|
display_name: spec.displayName,
|
|
19525
20959
|
kind: spec.kind,
|
|
19526
20960
|
token_present: !!process.env[spec.envKey],
|
|
19527
|
-
verifier: spec.verifier ? "available" : "unsupported",
|
|
20961
|
+
verifier: spec.kind === "tunnel_token" ? "local_format" : spec.verifier ? "available" : "unsupported",
|
|
19528
20962
|
activation: spec.activation,
|
|
19529
20963
|
stale_consumers: this.providerSecretStaleConsumers(spec.envKey),
|
|
19530
20964
|
}));
|
|
@@ -19534,7 +20968,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19534
20968
|
// a child process's private environment safely, so report the conservative
|
|
19535
20969
|
// set of already-running children; the UI can then say "restart these"
|
|
19536
20970
|
// rather than claiming an existing process reloaded.
|
|
19537
|
-
if (envKey === "GROQ_API_KEY")
|
|
20971
|
+
if (envKey === "GROQ_API_KEY" || envKey === PUBLIC_TUNNEL_TOKEN_ENV)
|
|
19538
20972
|
return [];
|
|
19539
20973
|
return [...this.children.keys()].sort();
|
|
19540
20974
|
}
|
|
@@ -19552,10 +20986,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19552
20986
|
if (!spec || !this.providerSecretEnvAllowed(spec.envKey)) {
|
|
19553
20987
|
return { ok: false, status: "invalid", error: "provider secret is not configured" };
|
|
19554
20988
|
}
|
|
19555
|
-
|
|
20989
|
+
const tunnel = spec.kind === "tunnel_token";
|
|
20990
|
+
if (!tunnel && !spec.verifier)
|
|
19556
20991
|
return { ok: false, status: "unsupported_verifier", error: "this provider has no supported verifier" };
|
|
19557
|
-
|
|
19558
|
-
|
|
20992
|
+
const secret = tunnel && !input.clear ? normalizePublicTunnelToken(input.secret) : input.secret;
|
|
20993
|
+
if (input.clear && (!tunnel || input.secret !== "")
|
|
20994
|
+
|| !input.clear && (!secret || secret.length > 4096 || /[\r\n\0]/.test(secret) || /[^\x20-\x7e]/.test(secret))) {
|
|
20995
|
+
return { ok: false, status: "invalid", error: tunnel ? PUBLIC_TUNNEL_TOKEN_HINT : "secret is invalid" };
|
|
19559
20996
|
}
|
|
19560
20997
|
const challengeKey = `${input.sessionBinding}:api_key:${spec.id}:${spec.envKey}:${input.idempotencyKey}`;
|
|
19561
20998
|
const existingId = this.providerSecretChallengesByKey.get(challengeKey);
|
|
@@ -19565,7 +21002,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19565
21002
|
}
|
|
19566
21003
|
if (existingId)
|
|
19567
21004
|
this.providerSecretChallengesByKey.delete(challengeKey);
|
|
19568
|
-
const result = await verifyProviderSecret(spec, input.secret, this.providerSecretHttpClient);
|
|
21005
|
+
const result = tunnel ? { ok: true } : await verifyProviderSecret(spec, input.secret, this.providerSecretHttpClient);
|
|
19569
21006
|
if (!result.ok) {
|
|
19570
21007
|
// Do not log provider detail: the HTTP verifier already redacted it and
|
|
19571
21008
|
// this endpoint has no need to disclose whether a key was close to valid.
|
|
@@ -19583,7 +21020,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19583
21020
|
operation: "provider-secret.apply",
|
|
19584
21021
|
idempotencyKey: input.idempotencyKey,
|
|
19585
21022
|
expiresAt, deadline: performance.now() + SECRET_CHALLENGE_TTL_MS,
|
|
19586
|
-
secret:
|
|
21023
|
+
secret: secret,
|
|
19587
21024
|
};
|
|
19588
21025
|
this.providerSecretChallenges.set(challenge.id, challenge);
|
|
19589
21026
|
this.providerSecretChallengesByKey.set(challengeKey, challenge.id);
|
|
@@ -19653,6 +21090,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19653
21090
|
async runProviderSecretApply(job, secret, execution) {
|
|
19654
21091
|
const spec = providerSecretSpec(job.specId);
|
|
19655
21092
|
const allowed = new Set([
|
|
21093
|
+
PUBLIC_TUNNEL_TOKEN_ENV,
|
|
19656
21094
|
...PROVIDER_SECRET_SPECS.map(item => item.envKey),
|
|
19657
21095
|
...(this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).map(channel => channel.bot_token_env),
|
|
19658
21096
|
]);
|
|
@@ -19666,7 +21104,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19666
21104
|
// Construct inside the transaction: symlink/permission refusal must
|
|
19667
21105
|
// settle the job as a safe failure, not escape the queued microtask.
|
|
19668
21106
|
store = new SecretStore(join(this.dataDir, ".env"), allowed, { owner: execution?.owner });
|
|
19669
|
-
const write = () => {
|
|
21107
|
+
const write = () => {
|
|
21108
|
+
before = spec.kind === "tunnel_token" && !secret ? store.clear(job.envKey) : store.write(job.envKey, secret);
|
|
21109
|
+
wrote = true;
|
|
21110
|
+
if (secret)
|
|
21111
|
+
process.env[job.envKey] = secret;
|
|
21112
|
+
else
|
|
21113
|
+
delete process.env[job.envKey];
|
|
21114
|
+
};
|
|
19670
21115
|
if (execution)
|
|
19671
21116
|
execution.mutate(write);
|
|
19672
21117
|
else
|
|
@@ -19688,12 +21133,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19688
21133
|
job.finishedAt = Date.now();
|
|
19689
21134
|
}
|
|
19690
21135
|
catch (err) {
|
|
19691
|
-
const safe = safeSecretError(err, secret);
|
|
21136
|
+
const safe = spec?.kind === "tunnel_token" ? "connector token change failed" : safeSecretError(err, secret);
|
|
19692
21137
|
this.logger.warn({ specId: job.specId, reason: safe }, "Provider API-key apply failed");
|
|
19693
21138
|
try {
|
|
19694
21139
|
// Memory and disk have separate receipts: a host-edited file must
|
|
19695
21140
|
// remain, but cannot strand our owned process value or hook snapshot.
|
|
19696
|
-
if (wrote && process.env[job.envKey] === secret) {
|
|
21141
|
+
if (wrote && (process.env[job.envKey] ?? "") === secret) {
|
|
19697
21142
|
if (previousProcessValue === undefined)
|
|
19698
21143
|
delete process.env[job.envKey];
|
|
19699
21144
|
else
|
|
@@ -19733,6 +21178,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19733
21178
|
* hot activation contract explicit and gives tests a failure seam; no generic
|
|
19734
21179
|
* SIGHUP or caller-provided hook is ever executed. */
|
|
19735
21180
|
async runProviderSecretReloadHook(hookId, _next, _previous) {
|
|
21181
|
+
if (hookId === "cloudflare.public_link") {
|
|
21182
|
+
await this.publicWebLink?.close("connector token changed");
|
|
21183
|
+
return;
|
|
21184
|
+
}
|
|
19736
21185
|
if (hookId !== "groq.voice")
|
|
19737
21186
|
throw new Error("unknown provider secret reload hook");
|
|
19738
21187
|
const before = this.providerSecretHotSnapshots.get(hookId);
|
|
@@ -20405,7 +21854,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20405
21854
|
all = false;
|
|
20406
21855
|
const message = err?.message ?? String(err);
|
|
20407
21856
|
this.adapterState.set(id, { status: "retrying", retryCount: 0, lastError: message });
|
|
20408
|
-
this.scheduleAdapterRetry(id
|
|
21857
|
+
this.scheduleAdapterRetry(id);
|
|
20409
21858
|
}
|
|
20410
21859
|
}
|
|
20411
21860
|
this.classicChannels?.configureAdapters(this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []));
|
|
@@ -20582,7 +22031,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20582
22031
|
await ipc.close();
|
|
20583
22032
|
}
|
|
20584
22033
|
this.instanceIpcClients.clear();
|
|
20585
|
-
await
|
|
22034
|
+
await this.stopInstancesInBatches(instanceNames, name => this.stopInstance(name));
|
|
20586
22035
|
// Kill remaining orphan windows to prevent stale state on restart
|
|
20587
22036
|
try {
|
|
20588
22037
|
const agendNames = new Set(instanceNames);
|
|
@@ -20715,7 +22164,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20715
22164
|
// up to 15s each, and on a beta build BOTH ran — 30s with no WATCHDOG ping,
|
|
20716
22165
|
// past WatchdogSec's half-interval and enough for systemd to SIGABRT the fleet
|
|
20717
22166
|
// for a background version check.
|
|
20718
|
-
const { execFile } = await import("
|
|
22167
|
+
const { execFile } = await import("./child-process.js");
|
|
20719
22168
|
const { promisify } = await import("node:util");
|
|
20720
22169
|
const execFileP = promisify(execFile);
|
|
20721
22170
|
const npmVersion = async (spec) => {
|
|
@@ -20903,6 +22352,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20903
22352
|
if (this.guestScope(owner).size)
|
|
20904
22353
|
return "ok";
|
|
20905
22354
|
if (owner && this.fleetConfig?.web?.guests?.enabled === true) {
|
|
22355
|
+
// Before adapters initialize, topic scope is unknown, not revoked. Keep the returning cookie but grant no
|
|
22356
|
+
// request until its configured world exists. Removed connections and initialized denied access stay final.
|
|
22357
|
+
const channels = this.fleetConfig.channels ?? (this.fleetConfig.channel ? [this.fleetConfig.channel] : []);
|
|
22358
|
+
if (this.startupDashboard && !this.worlds.has(owner.adapterId)
|
|
22359
|
+
&& channels.some(channel => (channel.id ?? channel.type) === owner.adapterId))
|
|
22360
|
+
return "pending";
|
|
20906
22361
|
const members = this.guestRooms(), rooms = guestGroupRooms(this.classicChannels?.getAll() ?? [], owner);
|
|
20907
22362
|
if (rooms.some((room) => members.pending(room, owner.userId))) {
|
|
20908
22363
|
void this.refreshGuestRooms(owner, { mode: "triggered" }); // rate-limited per room (30 s), single-flight
|
|
@@ -21001,16 +22456,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21001
22456
|
res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
|
|
21002
22457
|
return;
|
|
21003
22458
|
}
|
|
21004
|
-
if (closing) {
|
|
22459
|
+
if (closing || this.shuttingDown) {
|
|
21005
22460
|
res.writeHead(503, { Connection: "close", "Retry-After": "10" });
|
|
21006
22461
|
res.end(JSON.stringify({ error: "fleet stopping" }));
|
|
21007
22462
|
return;
|
|
21008
22463
|
}
|
|
21009
22464
|
const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
|
|
22465
|
+
if (!this.canvasWorkerEnabled() && isCanvasWorkerHttpPath(requestPath)) {
|
|
22466
|
+
res.writeHead(403, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
22467
|
+
res.end(JSON.stringify({ error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE }));
|
|
22468
|
+
return;
|
|
22469
|
+
}
|
|
21010
22470
|
if (this.effectBootstrap.handle(req, res))
|
|
21011
22471
|
return;
|
|
21012
22472
|
// Branding is public, after Host admission: automatic favicon requests
|
|
21013
22473
|
// must not become noisy web-token 401s in the browser console.
|
|
22474
|
+
// #2308: the icon routes and the pages' icon links follow the app icon set in Settings.
|
|
22475
|
+
useAppIconStore(this.appIcon);
|
|
21014
22476
|
if (handleWebIconRequest(req, res, requestPath))
|
|
21015
22477
|
return;
|
|
21016
22478
|
// Public: the health probe, /agent (instance-token auth of its own), the
|
|
@@ -21042,6 +22504,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21042
22504
|
// #1920: a guest reaches only GUEST_ROUTES, within the scope derived for it now — default deny, before any
|
|
21043
22505
|
// handler, on this listener and the public link's gateway alike (both come through here).
|
|
21044
22506
|
if (decision.via === "session" && decision.session.tier === "guest") {
|
|
22507
|
+
// A browser opening a page gets a page in its language, not the JSON an API caller gets (#2399 smoke).
|
|
22508
|
+
const navigating = req.method === "GET" && String(req.headers.accept ?? "").includes("text/html") && isWebPageNavigation(requestPath);
|
|
21045
22509
|
const owner = decision.session.owner;
|
|
21046
22510
|
const admitted = decision.session;
|
|
21047
22511
|
const admission = this.guestAdmission(admitted);
|
|
@@ -21052,12 +22516,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21052
22516
|
return;
|
|
21053
22517
|
}
|
|
21054
22518
|
if (admission === "gone") {
|
|
22519
|
+
if (navigating) {
|
|
22520
|
+
serveGuestRefusedPage(res, 401, "ended");
|
|
22521
|
+
return;
|
|
22522
|
+
}
|
|
21055
22523
|
res.writeHead(401);
|
|
21056
22524
|
res.end(JSON.stringify({ error: "guest_scope_empty" }));
|
|
21057
22525
|
return;
|
|
21058
22526
|
}
|
|
21059
22527
|
const verdict = guestRouteDecision(req.method ?? "GET", parsedUrl, this.guestScope(owner));
|
|
21060
22528
|
if (verdict.kind === "deny") {
|
|
22529
|
+
if (navigating) {
|
|
22530
|
+
serveGuestRefusedPage(res, verdict.status, "members");
|
|
22531
|
+
return;
|
|
22532
|
+
}
|
|
21061
22533
|
res.writeHead(verdict.status);
|
|
21062
22534
|
res.end(JSON.stringify({ error: verdict.error }));
|
|
21063
22535
|
return;
|
|
@@ -21073,6 +22545,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21073
22545
|
// #1920 §5.1: guests chat unless the host made them read-only (web.guests.chat: false).
|
|
21074
22546
|
chat: () => this.fleetConfig?.web?.guests?.chat !== false });
|
|
21075
22547
|
}
|
|
22548
|
+
if (decision.via === "session" && this.webToken) {
|
|
22549
|
+
const upgrade = sessionCookieUpgrade(req, this.webToken, decision.session);
|
|
22550
|
+
// No cookie header is set before this gate; auth routes own their headers separately.
|
|
22551
|
+
if (upgrade)
|
|
22552
|
+
res.setHeader("Set-Cookie", upgrade);
|
|
22553
|
+
}
|
|
21076
22554
|
}
|
|
21077
22555
|
if (req.method === "GET" && req.url === "/health") {
|
|
21078
22556
|
const health = this.getFleetHealth();
|
|
@@ -21084,6 +22562,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21084
22562
|
res.end(JSON.stringify(health));
|
|
21085
22563
|
return;
|
|
21086
22564
|
}
|
|
22565
|
+
// Threat: lifecycle/config/chat writes racing boot-owned starts and reconciliation. Reads stay live;
|
|
22566
|
+
// authentication keeps its own checks so a browser can sign in or sign out while agents start.
|
|
22567
|
+
if (this.startupDashboard && !["GET", "HEAD", "OPTIONS"].includes(req.method ?? "GET")
|
|
22568
|
+
&& !requestPath.startsWith("/auth/")) {
|
|
22569
|
+
res.writeHead(503, { "Retry-After": "2", "Cache-Control": "no-store", Connection: "close" });
|
|
22570
|
+
res.end(JSON.stringify({ code: "fleet_starting", error: t("fleet.starting_retry") }));
|
|
22571
|
+
return;
|
|
22572
|
+
}
|
|
21087
22573
|
if (req.method === "GET" && req.url === "/status") {
|
|
21088
22574
|
const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => {
|
|
21089
22575
|
const statusFile = join(this.getInstanceDir(name), "statusline.json");
|
|
@@ -21354,6 +22840,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21354
22840
|
}
|
|
21355
22841
|
}
|
|
21356
22842
|
startHealthServer(port) {
|
|
22843
|
+
let settled;
|
|
22844
|
+
const listening = new Promise(resolve => { settled = resolve; });
|
|
21357
22845
|
this.startedAt = Date.now();
|
|
21358
22846
|
this.healthServerListening = false;
|
|
21359
22847
|
this.healthPortRetried = false;
|
|
@@ -21368,6 +22856,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21368
22856
|
const drain = trackWebServer(server);
|
|
21369
22857
|
this.healthServer = server;
|
|
21370
22858
|
this.healthServerDrain = drain;
|
|
22859
|
+
server.once("close", settled);
|
|
21371
22860
|
const markListening = (afterTakeover = false) => {
|
|
21372
22861
|
this.healthServerListening = true;
|
|
21373
22862
|
this.logger.info({ port }, afterTakeover
|
|
@@ -21381,6 +22870,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21381
22870
|
// #1306: the preview listener starts once the web listener is bound — its frame-ancestors name the real port.
|
|
21382
22871
|
const bound = this.healthServer?.address();
|
|
21383
22872
|
this.startPreviewListener(port, bound && typeof bound === "object" ? bound.port : port);
|
|
22873
|
+
settled();
|
|
21384
22874
|
};
|
|
21385
22875
|
this.healthServer.on("error", (err) => {
|
|
21386
22876
|
this.healthServerListening = false;
|
|
@@ -21388,6 +22878,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21388
22878
|
if (this.healthPortRetried) {
|
|
21389
22879
|
this.logger.error({ err, port }, "Health port still in use after takeover — dashboard disabled");
|
|
21390
22880
|
this.notifyFleetError(t("dashboard.port_in_use", port));
|
|
22881
|
+
settled();
|
|
21391
22882
|
return;
|
|
21392
22883
|
}
|
|
21393
22884
|
this.healthPortRetried = true;
|
|
@@ -21423,16 +22914,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21423
22914
|
this.logger.debug({ err }, "Old fleet process kill skipped (already gone or no permission)");
|
|
21424
22915
|
}
|
|
21425
22916
|
setTimeout(() => {
|
|
21426
|
-
if (
|
|
22917
|
+
if (this.healthServer !== server || this.shuttingDown) {
|
|
22918
|
+
settled();
|
|
21427
22919
|
return;
|
|
21428
|
-
|
|
22920
|
+
}
|
|
22921
|
+
server.listen(port, "127.0.0.1", () => markListening(true));
|
|
21429
22922
|
}, 1500);
|
|
21430
22923
|
return;
|
|
21431
22924
|
}
|
|
21432
22925
|
this.logger.error({ err, port }, "Health server error");
|
|
21433
22926
|
this.notifyFleetError(t("dashboard.server_failed", err.message));
|
|
22927
|
+
settled();
|
|
21434
22928
|
});
|
|
21435
22929
|
this.healthServer.listen(port, "127.0.0.1", () => markListening());
|
|
22930
|
+
return listening;
|
|
21436
22931
|
}
|
|
21437
22932
|
/**
|
|
21438
22933
|
* #1306: the preview listener, beside the web listener and closed with it. When it cannot listen (the port is
|
|
@@ -21454,6 +22949,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21454
22949
|
return;
|
|
21455
22950
|
}
|
|
21456
22951
|
const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig,
|
|
22952
|
+
canvasWorkerEnabled: () => this.canvasWorkerEnabled(),
|
|
21457
22953
|
// #1723: with a web.preview_origin, the open public link's dashboard frames this listener's preview too.
|
|
21458
22954
|
extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; } });
|
|
21459
22955
|
this.previewListener = listener;
|
|
@@ -21576,15 +23072,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21576
23072
|
this.logger.debug({ err }, "bots unreadable (getUiStatus)");
|
|
21577
23073
|
}
|
|
21578
23074
|
const instances = names.map(name => {
|
|
21579
|
-
|
|
21580
|
-
|
|
21581
|
-
|
|
21582
|
-
const data = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
21583
|
-
cost = data.cost?.total_cost_usd ?? 0;
|
|
21584
|
-
}
|
|
21585
|
-
catch (err) {
|
|
21586
|
-
this.logger.debug({ err, name }, "statusline.json read failed (getUiStatus)");
|
|
21587
|
-
}
|
|
23075
|
+
// #1956: read once for this instance; its cost, context and model come from it.
|
|
23076
|
+
const statusline = readStatuslineJson(this.dataDir, name);
|
|
23077
|
+
const cost = statusline?.cost?.total_cost_usd ?? 0;
|
|
21588
23078
|
// Align with /ctx: statusline for claude-code, pane scrape for kiro/grok/codex.
|
|
21589
23079
|
const classic = classicOnly.includes(name);
|
|
21590
23080
|
const backend = classic
|
|
@@ -21592,14 +23082,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21592
23082
|
: (this.fleetConfig?.instances[name]?.backend
|
|
21593
23083
|
?? this.fleetConfig?.defaults?.backend
|
|
21594
23084
|
?? "claude-code");
|
|
21595
|
-
const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name) });
|
|
23085
|
+
const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name), statusline });
|
|
21596
23086
|
// context_pct: null when unavailable, not 0
|
|
21597
23087
|
const context_pct = context ?? null;
|
|
21598
23088
|
// Model: Only Claude Code has live statusline; others use the effective resolver.
|
|
21599
|
-
//
|
|
23089
|
+
// statuslineModel gives the /ctx-aligned display_name+id combo for Claude (readStatuslineModel's rule).
|
|
21600
23090
|
// Non-Claude backends must NOT read statusline.json model (may be stale from previous Claude run).
|
|
21601
23091
|
const resolved = this.resolveInstanceModel(name);
|
|
21602
|
-
const liveModel = backend === "claude-code" ?
|
|
23092
|
+
const liveModel = backend === "claude-code" ? statuslineModel(statusline) : null;
|
|
21603
23093
|
// Display value: live model for Claude, resolved.display for others (includes "auto (default)" for Kiro)
|
|
21604
23094
|
const model = liveModel ?? resolved.display;
|
|
21605
23095
|
// model_source: "live" when Claude statusline succeeded, else the resolver's source
|
|
@@ -21637,7 +23127,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21637
23127
|
return {};
|
|
21638
23128
|
} })(),
|
|
21639
23129
|
// #2118: only this Chat's placement content; guest projection drops global revisions.
|
|
21640
|
-
effect_chat_v: createHash("sha256").update(JSON.stringify(this.themeStore.get(name).placements.effects)).digest("hex").slice(0, 32),
|
|
23130
|
+
effect_chat_v: this.canvasWorkerEnabled() ? createHash("sha256").update(JSON.stringify(this.themeStore.get(name).placements.effects)).digest("hex").slice(0, 32) : null,
|
|
21641
23131
|
// #1958: the identity colour's tiers (the page resolves them: the agent's only while it shows agents' themes).
|
|
21642
23132
|
color: { ...(colors.get(name)?.color ? { person: colors.get(name).color } : {}), ...(image.agentColor ? { agent: image.agentColor } : {}),
|
|
21643
23133
|
...(image.roleColor ? { role: image.roleColor } : {}), auto: autoIdentityColor(name) },
|
|
@@ -21646,6 +23136,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21646
23136
|
description: identity.description || undefined,
|
|
21647
23137
|
role: identity.role || undefined,
|
|
21648
23138
|
tags: identity.tags,
|
|
23139
|
+
teams: instanceTeams(this.fleetConfig?.teams, name), // #2172: the sidebar groups a team's members under it
|
|
23140
|
+
last_reply: this.webChatHistory.lastAgentReply(name) ?? undefined, // #2179: its last reply to a person (ISO)
|
|
21649
23141
|
status: this.getInstanceStatus(name),
|
|
21650
23142
|
// `state` (presentation: may be awaiting_input) and `execution_state` (working / idle / stuck, or null —
|
|
21651
23143
|
// what the dashboard's activity events carry) come from instancePresentation (#1212).
|
|
@@ -21662,7 +23154,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21662
23154
|
};
|
|
21663
23155
|
});
|
|
21664
23156
|
return {
|
|
23157
|
+
canvas_worker: this.canvasWorkerEnabled(),
|
|
21665
23158
|
instances,
|
|
23159
|
+
startup: this.startupDashboard?.snapshot() ?? null,
|
|
21666
23160
|
publicLink: this.getPublicWebStatus(),
|
|
21667
23161
|
uptime: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
21668
23162
|
// #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.
|