@songsid/agend 2.3.0-beta.8 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +9 -2
- package/dist/agent-cli.js +44 -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 +54 -11
- 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.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 +6 -0
- package/dist/channel/adapters/telegram.js +54 -14
- 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 +43 -6
- 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 +443 -311
- 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 +39 -1
- package/dist/daemon.js +234 -15
- 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.d.ts +25 -0
- package/dist/decision-list.js +30 -0
- package/dist/decision-list.js.map +1 -0
- package/dist/delivery-consumed-watch.js.map +1 -1
- package/dist/delivery-outbox.js +5 -0
- 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-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.js +3 -2
- package/dist/fleet-lock.js.map +1 -1
- package/dist/fleet-manager.d.ts +258 -15
- package/dist/fleet-manager.js +1605 -304
- 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.d.ts +36 -0
- package/dist/fleet-traffic.js +100 -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/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 +28 -4
- package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +553 -0
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +170 -35
- 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 +10 -1
- package/dist/instance-lifecycle.js +86 -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 +428 -20
- package/dist/locale.js.map +1 -1
- 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 +38 -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 +3 -0
- package/dist/outbound-handlers.js +76 -19
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +112 -64
- package/dist/outbound-schemas.js +39 -12
- 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 +141 -0
- package/dist/search/search-index.js +440 -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 +75 -0
- package/dist/service-installer.js +206 -0
- 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.d.ts +21 -0
- package/dist/session-oom.js +139 -0
- package/dist/session-oom.js.map +1 -0
- package/dist/settings-api.d.ts +13 -0
- package/dist/settings-api.js +73 -12
- 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 +5 -0
- package/dist/spawn-gate.js +28 -8
- package/dist/spawn-gate.js.map +1 -1
- package/dist/start-attempt.d.ts +1 -0
- package/dist/start-attempt.js +18 -0
- package/dist/start-attempt.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 +147 -0
- package/dist/start-failure.js +281 -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/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 +9 -42
- 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 +55 -2
- package/dist/ui/app-effects.js +27 -5
- package/dist/ui/app.html +2 -0
- package/dist/ui/backend-signin.js +297 -0
- package/dist/ui/chat-effects.js +15 -7
- package/dist/ui/chat-msg-actions.js +176 -0
- package/dist/ui/chat-render.js +67 -20
- package/dist/ui/chat-store.js +218 -48
- package/dist/ui/chat-strings.js +42 -10
- package/dist/ui/chat-thread.js +249 -23
- package/dist/ui/effect-trial-controller.js +13 -8
- 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 +120 -43
- 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 +21 -8
- 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 +227 -45
- 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 +282 -89
- package/dist/ui/panel-details.js +2 -0
- package/dist/ui/panel-fleet.js +32 -71
- package/dist/ui/panel-needs.js +23 -6
- package/dist/ui/panel-settings.js +158 -32
- 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 +180 -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 +3 -1
- 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 +172 -28
- package/dist/ui/shared/app-store.js +3 -2
- package/dist/ui/shared/app-stream.js +7 -2
- package/dist/ui/shared/app-themes.js +116 -1
- package/dist/ui/shared/app.css +681 -54
- 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-policy.js +2 -2
- package/dist/ui/shared/effect-registry.js +30 -11
- package/dist/ui/shared/effect-runtime-loader.js +3 -1
- package/dist/ui/shared/effect-stock-snapshot.js +9 -0
- package/dist/ui/shared/effect-stock-trust.js +40 -9
- package/dist/ui/shared/effect-trial-source.js +1 -1
- package/dist/ui/shared/effect-trial-stage.js +27 -21
- package/dist/ui/shared/effect-trial.css +1 -1
- 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 +13 -3
- 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 +206 -16
- package/dist/ui/shared/view-strings.js +68 -4
- package/dist/ui/shared/web-authorize.js +50 -1
- package/dist/ui/signin.html +2 -0
- package/dist/ui/web-terminal/terminal.css +4 -4
- 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 +9 -2
- package/dist/web-ambient.js.map +1 -1
- package/dist/web-api.d.ts +58 -10
- 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 +4 -1
- 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,15 +193,18 @@ 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";
|
|
186
|
-
import { TrafficLog, trafficItem } from "./fleet-traffic.js";
|
|
202
|
+
import { Delegations, TrafficLog, trafficItem } from "./fleet-traffic.js";
|
|
187
203
|
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,7 @@ 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 { idleGateTimeoutLog } from "./idle-gate-log.js";
|
|
203
221
|
import { DEFAULT_STUCK_TIMEOUT_MS, readLastInboundAt } from "./daemon.js";
|
|
204
222
|
import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
|
|
205
223
|
import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
|
|
@@ -222,9 +240,10 @@ import { CompactWatch, launchIdentity } from "./compact-watch.js";
|
|
|
222
240
|
import { agyLegacyAgentsFile, danglingSymlink, migrateAgyAgentsFile } from "./backend/antigravity.js";
|
|
223
241
|
import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
|
|
224
242
|
import { sharedRolloutIndex } from "./rollout-index.js";
|
|
243
|
+
import { TASK_BOARD_ON_NOTICE, dashboardRefusalIn, taskBoardSwitchedOn } from "./dashboard-permissions.js";
|
|
225
244
|
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";
|
|
245
|
+
import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken, sessionCookieUpgrade } from "./web-auth.js";
|
|
246
|
+
import { bypassesWebGate, handleAuthRequest, serveGuestRefusedPage, serveSigninPage } from "./auth-api.js";
|
|
228
247
|
import { isWebPageNavigation } from "./web-shell-routes.js";
|
|
229
248
|
import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
|
|
230
249
|
import { authorityNow, evaluateWebAuthority, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
|
|
@@ -232,25 +251,31 @@ import { AuthorityStateStore } from "./authority/authority-state.js";
|
|
|
232
251
|
import { SessionAuthorizations } from "./authority/session-authorizations.js";
|
|
233
252
|
import { SessionAuthorizationRequests } from "./web-session-authorization.js";
|
|
234
253
|
import { GuestLoginCodes, WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
|
|
254
|
+
import { decisionListAnswer } from "./decision-list.js";
|
|
235
255
|
import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
|
|
236
256
|
import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, LOOPBACK_HOST_NAMES, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
|
|
237
257
|
import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
|
|
258
|
+
import { canvasWorkerEnabled, isCanvasWorkerTool, CANVAS_WORKER_EXPERIMENTAL_MESSAGE } from "./web-experimental.js";
|
|
259
|
+
import { isCanvasWorkerHttpPath } from "./web-experimental-http.js";
|
|
260
|
+
import { bundledSkillContents } from "./bundled-skills.js";
|
|
238
261
|
import { createEffectBootstrap } from "./web-effect-bootstrap.js";
|
|
239
262
|
import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
|
|
240
263
|
import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
|
|
241
264
|
import { SecretStore } from "./secret-store.js";
|
|
242
265
|
import { opaqueId, safeSecretError, SECRET_CHALLENGE_TTL_MS, } from "./connection-secrets.js";
|
|
243
266
|
import { verifyDiscordToken, verifyTelegramToken } from "./provider-probe.js";
|
|
244
|
-
import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isReservedProviderEnvKey, verifyProviderSecret, } from "./provider-secret-registry.js";
|
|
267
|
+
import { PROVIDER_SECRET_SPECS, PUBLIC_TUNNEL_SECRET_SPEC, providerSecretSpec, providerRegistryEnvKeys, isReservedProviderEnvKey, verifyProviderSecret, } from "./provider-secret-registry.js";
|
|
245
268
|
/** A self-restart is a whole service restart; 120s is the apply budget, not this. */
|
|
246
269
|
const SELF_RESTART_DEADLINE_MS = 300_000;
|
|
247
270
|
import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
|
|
248
|
-
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
271
|
+
import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileLoginCommand, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
|
|
249
272
|
import { carryClaudeConversation } from "./backend/claude-carry.js";
|
|
250
273
|
import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
|
|
251
274
|
import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
|
|
252
|
-
import { cleanupWatchVerdict, formatRestartProgressCompletion,
|
|
275
|
+
import { cleanupWatchVerdict, formatRestartProgressCompletion, RestartProgress, } from "./restart-progress.js";
|
|
276
|
+
import { FleetStartup } from "./fleet-startup.js";
|
|
253
277
|
import { launchFullRestartHelper } from "./full-restart.js";
|
|
278
|
+
import { fullReloadExitCode } from "./service-owner.js";
|
|
254
279
|
import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
|
|
255
280
|
import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
|
|
256
281
|
import { StormWindow } from "./storm-window.js";
|
|
@@ -258,6 +283,14 @@ import { SpawnGate } from "./spawn-gate.js";
|
|
|
258
283
|
import { BackendOutageTracker } from "./backend-outage.js";
|
|
259
284
|
import { canUnlockAdvancedTips, DailyTipScheduler, selectTip, visibleTipLevels, } from "./tips.js";
|
|
260
285
|
import { getTmuxSession } from "./config.js";
|
|
286
|
+
// Closed migration cohort: these 11 names existed immediately before the first
|
|
287
|
+
// managed-skill publisher (a1ac499f; parent e267e684). Never grow this set when
|
|
288
|
+
// adding a bundled skill: its new name is not proof of old AgEnD ownership.
|
|
289
|
+
const PRE_MANIFEST_KIRO_SKILLS = new Set([
|
|
290
|
+
"backend-providers", "cross-instance-messaging", "fleet-config", "fleet-health",
|
|
291
|
+
"fleet-restart", "instance-lifecycle", "model-discovery", "multi-channel",
|
|
292
|
+
"scheduling", "session-management", "tui-effort",
|
|
293
|
+
]);
|
|
261
294
|
export function resolveReplyThreadId(argsThreadId, instanceConfig) {
|
|
262
295
|
if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
|
|
263
296
|
return argsThreadId;
|
|
@@ -294,6 +327,41 @@ export function selectLruEvictions(warm, cap, opts) {
|
|
|
294
327
|
* fleet.yaml. Keep in sync with `uniqueInstanceName` — every name it emits
|
|
295
328
|
* must match here, or a deleted instance's CLI is left running (#1305 P2-3).
|
|
296
329
|
*/
|
|
330
|
+
/**
|
|
331
|
+
* #2163: where `p` really is — symlinks resolved. A path that does not exist yet is resolved through its deepest
|
|
332
|
+
* existing ancestor (a missing child of a link elsewhere is elsewhere too).
|
|
333
|
+
*/
|
|
334
|
+
function realPathThroughAncestors(p) {
|
|
335
|
+
const missing = [];
|
|
336
|
+
let at = resolvePath(p);
|
|
337
|
+
for (;;) {
|
|
338
|
+
try {
|
|
339
|
+
return join(realpathSync(at), ...missing.reverse());
|
|
340
|
+
}
|
|
341
|
+
catch { /* not there yet: try its parent */ }
|
|
342
|
+
try {
|
|
343
|
+
lstatSync(at);
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
catch { /* truly absent — not a broken link whose target is unknown */ }
|
|
347
|
+
const up = dirname(at);
|
|
348
|
+
if (up === at)
|
|
349
|
+
return resolvePath(p);
|
|
350
|
+
missing.push(basename(at));
|
|
351
|
+
at = up;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* #2163: whether `dir` is inside AgEnD's own workspaces (<AGEND_HOME>/workspaces/<name>/…), compared after resolving
|
|
356
|
+
* symlinks on both sides — including a missing folder's existing ancestors — so a link elsewhere is not inside, nor is a broken link.
|
|
357
|
+
*/
|
|
358
|
+
export function insideManagedWorkspaces(dir, agendHome = getAgendHome()) {
|
|
359
|
+
const root = realPathThroughAncestors(join(agendHome, "workspaces")), real = realPathThroughAncestors(dir);
|
|
360
|
+
if (root === null || real === null)
|
|
361
|
+
return false;
|
|
362
|
+
const rel = relative(root, real);
|
|
363
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
364
|
+
}
|
|
297
365
|
export function isOrphanInstanceWindowName(name) {
|
|
298
366
|
return /-t\d+$/.test(name) || /^classic-/.test(name);
|
|
299
367
|
}
|
|
@@ -553,6 +621,8 @@ export class FleetManager {
|
|
|
553
621
|
fleetConfig = null;
|
|
554
622
|
rawFleetConfig = {};
|
|
555
623
|
rawFleetDocument = null;
|
|
624
|
+
/** #2208: the paths a saveFleetConfig() wrote, for its check that nothing else changed. */
|
|
625
|
+
fleetDocumentWrites = null;
|
|
556
626
|
savedFleetConfigSnapshot = null;
|
|
557
627
|
adapter = null;
|
|
558
628
|
worlds = new Map();
|
|
@@ -608,7 +678,8 @@ export class FleetManager {
|
|
|
608
678
|
]);
|
|
609
679
|
logger = createLogger("info");
|
|
610
680
|
/** Report each bundled skill collision once per fleet, across instance starts. */
|
|
611
|
-
|
|
681
|
+
/** #2482: per skills directory, the skipped set (names + their content) last said — this process's view of the record. */
|
|
682
|
+
skippedSkillsSaid = new Map();
|
|
612
683
|
topicCommands;
|
|
613
684
|
// sessionName → instanceName mapping for external sessions
|
|
614
685
|
sessionRegistry = new Map();
|
|
@@ -616,7 +687,11 @@ export class FleetManager {
|
|
|
616
687
|
costGuard = null;
|
|
617
688
|
statuslineWatcher;
|
|
618
689
|
dailySummary = null;
|
|
690
|
+
/** #2246 step 3: the task board's daily summary for the person (task-board-summary.ts). */
|
|
691
|
+
taskBoardSummary = null;
|
|
619
692
|
dailyTipScheduler = null;
|
|
693
|
+
/** The task board's wakes (step 2): an instance hears when someone else changes a card it owns or handed out. */
|
|
694
|
+
taskBoardWaker = null;
|
|
620
695
|
webhookEmitter = null;
|
|
621
696
|
// Topic icon + auto-archive state
|
|
622
697
|
topicIcons = {};
|
|
@@ -745,7 +820,11 @@ export class FleetManager {
|
|
|
745
820
|
// Adapter restart: prevents re-entrant restart attempts
|
|
746
821
|
adapterRestarting = new Set();
|
|
747
822
|
// Adapter isolation: track state per adapter for retry + visibility
|
|
823
|
+
/** #2175: this boot's progress message, whose completion may still be owed by its background retry. */
|
|
824
|
+
startupProgress = null;
|
|
825
|
+
startupDashboard = null;
|
|
748
826
|
adapterState = new Map();
|
|
827
|
+
adapterRetryInFlight = new Map();
|
|
749
828
|
/** Web Settings secret rotation is deliberately separate from reconnect
|
|
750
829
|
* recovery: a rotation must build a fresh provider client with the new token,
|
|
751
830
|
* and stale callbacks from the old client must not win. */
|
|
@@ -787,6 +866,7 @@ export class FleetManager {
|
|
|
787
866
|
effectBootstrap = createEffectBootstrap();
|
|
788
867
|
/** Fixed trusted document coherence, independent of the HTML preview listener; never a resource grant. */
|
|
789
868
|
get effectBoot() { return this.effectBootstrap.boot; }
|
|
869
|
+
canvasWorkerEnabled() { return canvasWorkerEnabled(this.fleetConfig?.web); }
|
|
790
870
|
healthServerDrain = null;
|
|
791
871
|
/** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
|
|
792
872
|
previewListener = null;
|
|
@@ -824,6 +904,7 @@ export class FleetManager {
|
|
|
824
904
|
/** Days of event/activity history to keep. */
|
|
825
905
|
static EVENT_LOG_RETENTION_DAYS = 30;
|
|
826
906
|
watchdogTimer = null;
|
|
907
|
+
startupMemoryDeadline = null;
|
|
827
908
|
eventLoopWatch = null;
|
|
828
909
|
startedAt = 0;
|
|
829
910
|
stormOpenNotifyTimer = null;
|
|
@@ -852,11 +933,35 @@ export class FleetManager {
|
|
|
852
933
|
webChatHistory = new WebChatHistory();
|
|
853
934
|
/** #1565: the web chat history's files (set at fleet start, once the home and the instances are known). */
|
|
854
935
|
webChatStore = null;
|
|
936
|
+
/** #2293: the dashboard's global search (null: web.search.enabled is false, or the fleet has not started). */
|
|
937
|
+
searchIndex = null;
|
|
855
938
|
/** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
|
|
856
939
|
// #1937: the upload limits are the config's, read on each use (agend reload applies a change to the next upload).
|
|
857
940
|
webFiles = new WebFileLedger({ limits: () => uploadLimits(this.fleetConfig?.web) });
|
|
858
941
|
/** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
|
|
859
942
|
webReplyOrder = new Map();
|
|
943
|
+
/**
|
|
944
|
+
* #1774: an instance name's reply generation — moved on when a removal of it starts, so replies sent from then on (a
|
|
945
|
+
* same-name replacement's) are told from the removed instance's. `webChatRemovals`: once a removal succeeded, the
|
|
946
|
+
* generations and the daemon whose replies are no longer anyone's to show.
|
|
947
|
+
*/
|
|
948
|
+
webChatGenerations = new Map();
|
|
949
|
+
webChatRemovals = new Map();
|
|
950
|
+
/** #1774: a removal under way — its replies' fate waits for its outcome (success: theirs no more; failure: theirs). */
|
|
951
|
+
webChatRemovalsPending = new Map();
|
|
952
|
+
webChatGeneration(name) { return this.webChatGenerations.get(name) ?? 0; }
|
|
953
|
+
/** #1774: who an instance's reply is from, taken when it is sent: the name's generation and its daemon then. */
|
|
954
|
+
webChatIdentity(name) { return { generation: this.webChatGeneration(name), daemon: this.daemons.get(name) }; }
|
|
955
|
+
/** #1774: the reply's instance was not removed (a removal that failed changes nothing). */
|
|
956
|
+
webChatIdentityCurrent(name, id) {
|
|
957
|
+
const removed = this.webChatRemovals.get(name);
|
|
958
|
+
return !removed || (id.generation >= removed.below && (removed.daemon === undefined || id.daemon !== removed.daemon));
|
|
959
|
+
}
|
|
960
|
+
/** #1774: the removal still deciding this reply's fate (it was sent by what is being removed), or null. */
|
|
961
|
+
webChatRemovalPending(name, id) {
|
|
962
|
+
const pending = this.webChatRemovalsPending.get(name);
|
|
963
|
+
return pending && (id.generation < pending.below || (pending.daemon !== undefined && id.daemon === pending.daemon)) ? pending.done : null;
|
|
964
|
+
}
|
|
860
965
|
/** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
|
|
861
966
|
webReplyMaxBytes = REPLY_FILES_MAX_BYTES;
|
|
862
967
|
/** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
|
|
@@ -942,6 +1047,11 @@ export class FleetManager {
|
|
|
942
1047
|
// r4.1 Q3 (b): the card's limits are read from fleet.yaml at each upload (a change applies without a restart).
|
|
943
1048
|
return (this.themeStoreInstance ??= new ThemeStore(this.dataDir, { limits: () => effectiveAppearanceLimits(this.fleetConfig?.web?.appearance).limits, effectsDropped: ids => this.effectLibrary.collectUnreferenced(ids) }));
|
|
944
1049
|
}
|
|
1050
|
+
officeStoreInstance;
|
|
1051
|
+
/** Fleet → Org chart → Office: the offices people made (web-offices.json, beside web-themes.json). */
|
|
1052
|
+
get officeStore() { return (this.officeStoreInstance ??= new OfficeStore(this.dataDir)); }
|
|
1053
|
+
/** How often a person may save the offices (PUT /ui/offices); memory only, as the appearance limiter. */
|
|
1054
|
+
officeWrites = new OfficeWriteLimiter();
|
|
945
1055
|
/** #1629: the theme tools, a person's reset and lock, and the one appearance rate limiter (shared with effects). */
|
|
946
1056
|
get webAppearance() {
|
|
947
1057
|
return (this.webAppearanceService ??= new WebAppearance(this.themeStore, () => this.fleetConfig?.web ?? null, () => { try {
|
|
@@ -1031,6 +1141,7 @@ export class FleetManager {
|
|
|
1031
1141
|
/** #1629 r4 §1: get_web_appearance / preview_web_appearance — the caller's own look, resolved as the page resolves it. */
|
|
1032
1142
|
get appearanceTools() {
|
|
1033
1143
|
return (this.appearanceToolsService ??= new AppearanceTools({
|
|
1144
|
+
canvasWorkerEnabled: () => this.canvasWorkerEnabled(),
|
|
1034
1145
|
frame: (name) => (this.getUiStatus().instances.find(i => i.name === name) ?? { name }),
|
|
1035
1146
|
theme: (name) => {
|
|
1036
1147
|
const r = this.themeStore.get(name);
|
|
@@ -1075,6 +1186,7 @@ export class FleetManager {
|
|
|
1075
1186
|
prefs: this.webPrefs,
|
|
1076
1187
|
ownerCss: this.ownerCss,
|
|
1077
1188
|
effects: this.effectLibrary,
|
|
1189
|
+
canvasWorkerEnabled: () => this.canvasWorkerEnabled(),
|
|
1078
1190
|
context: () => ({ agendVersion: agendVersion(), limits: this.themeStore.limits(), effects: cw1EffectChecker() }),
|
|
1079
1191
|
isKnownInstance: (name) => this.isKnownInstance(name),
|
|
1080
1192
|
prefsIdentity: OPERATOR,
|
|
@@ -1113,6 +1225,11 @@ export class FleetManager {
|
|
|
1113
1225
|
const card = resolveCard(name, humanCards(this.dataDir).get(name), this.themeStore.get(name).theme.card);
|
|
1114
1226
|
return { ...(card.tagline ? { tagline: card.tagline.text } : {}), ...(card.bio ? { bio: card.bio.text } : {}) };
|
|
1115
1227
|
}
|
|
1228
|
+
appIconStore;
|
|
1229
|
+
/** #2308: the person's own app icon (Settings → Web appearance), under the state dir; none set: the bundled one. */
|
|
1230
|
+
get appIcon() {
|
|
1231
|
+
return (this.appIconStore ??= new AppIconStore(this.dataDir));
|
|
1232
|
+
}
|
|
1116
1233
|
ownerCssStore;
|
|
1117
1234
|
/** #1629 r4 §3: each person's own CSS; which version is in force is their `css.active` in the web preferences. */
|
|
1118
1235
|
get ownerCss() {
|
|
@@ -1125,6 +1242,7 @@ export class FleetManager {
|
|
|
1125
1242
|
*/
|
|
1126
1243
|
get webSettings() {
|
|
1127
1244
|
return (this.webSettingsToolsService ??= new WebSettingsTools({
|
|
1245
|
+
canvasWorkerEnabled: () => this.canvasWorkerEnabled(),
|
|
1128
1246
|
prefs: this.webPrefs, ownerCss: this.ownerCss, mods: this.mods, effects: this.effectService,
|
|
1129
1247
|
instances: () => [...Object.keys(this.fleetConfig?.instances ?? {}),
|
|
1130
1248
|
...(this.classicChannels?.getAll() ?? []).map((ch) => ch.instanceName).filter((n) => !Object.hasOwn(this.fleetConfig?.instances ?? {}, n))],
|
|
@@ -1242,9 +1360,11 @@ export class FleetManager {
|
|
|
1242
1360
|
return null;
|
|
1243
1361
|
const record = auth.session, sessions = this.webSessions, exposure = gatewayRequestContext(req);
|
|
1244
1362
|
return { id: record.idHash, label: record.label, source: exposure ? "public_link" : "web_session",
|
|
1363
|
+
localAdmin: () => record.tier === "admin" && record.surface === "local" && !exposure,
|
|
1245
1364
|
current: () => current() && sessions === this.webSessions && !!this.webToken
|
|
1246
1365
|
&& sessions.isCurrent(record, tokenEpoch(this.webToken)) && (!exposure || exposure.isCurrent()) };
|
|
1247
1366
|
}, baseline: () => baselines.read(), snapshot: () => baselines.snapshot(),
|
|
1367
|
+
selfConfirm: (req, id) => this.settingsSelfConfirmation(store, req, id),
|
|
1248
1368
|
job: async (id) => {
|
|
1249
1369
|
const settlement = this.settingsJobSettlements.get(id);
|
|
1250
1370
|
if (!settlement)
|
|
@@ -1259,6 +1379,93 @@ export class FleetManager {
|
|
|
1259
1379
|
this.settingsConfirmation = gate;
|
|
1260
1380
|
return gate;
|
|
1261
1381
|
}
|
|
1382
|
+
/**
|
|
1383
|
+
* #2170: a held change confirmed by the request itself — only when ALL hold, else the chat card or the host CLI, as
|
|
1384
|
+
* before:
|
|
1385
|
+
* - the session is web-verified (#1939: its account confirmed this browser on Discord or Telegram), asked now;
|
|
1386
|
+
* - the change's effect owners are known and on one platform (settingsChatAuthority — the chat card's own rule), the
|
|
1387
|
+
* verified account is on that platform and an admin of every one of them;
|
|
1388
|
+
* - a chat card could have been asked: a connected General on that platform the change does not affect (else the
|
|
1389
|
+
* change goes to the host CLI, as before) — the General that then hears of it;
|
|
1390
|
+
* - and, at execution (store.decide asks `current()` again): still web-verified under the same session and account
|
|
1391
|
+
* (not expired, revoked, signed out or the switch turned off), still those owners' admin, still a General a card
|
|
1392
|
+
* could go to, the fleet unchanged.
|
|
1393
|
+
* What it rests on: the account's one confirmation of this browser (#1939), kept for the session until it is revoked,
|
|
1394
|
+
* expires or signs out — not a fresh confirmation per Apply. Whoever uses that verified session (a browser left signed
|
|
1395
|
+
* in, its cookie) confirms the same way. A public link never qualifies (evaluateWebAuthority refuses it). General
|
|
1396
|
+
* still hears it: a notice, not a step.
|
|
1397
|
+
*/
|
|
1398
|
+
settingsSelfConfirmation(store, req, id) {
|
|
1399
|
+
const auth = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
|
|
1400
|
+
if (auth.kind !== "ok")
|
|
1401
|
+
return null;
|
|
1402
|
+
// #2453 (§0.2): never over the public link — not even a named-link session that is web-verified there.
|
|
1403
|
+
if (gatewayRequestContext(req))
|
|
1404
|
+
return null;
|
|
1405
|
+
// …and never a change that turns the named-link option on: that one needs a General's card or the host.
|
|
1406
|
+
if (!store.selfConfirmable(id))
|
|
1407
|
+
return null;
|
|
1408
|
+
const record = auth.session, gateway = !!gatewayRequestContext(req), peer = webPeerOf(req);
|
|
1409
|
+
const verified = () => {
|
|
1410
|
+
const now = this.webAuthority({ session: record, gateway, peer });
|
|
1411
|
+
// A refusal carries no authority (kind "none"); the handle pins the session, and with it its one account.
|
|
1412
|
+
return now.authority.kind === "web-verified" && now.authority.actor && now.authority.session?.handle === record.handle ? now.authority.actor : null;
|
|
1413
|
+
};
|
|
1414
|
+
const actor = verified();
|
|
1415
|
+
if (!actor)
|
|
1416
|
+
return null;
|
|
1417
|
+
const chat = this.settingsChatAuthority(store, id);
|
|
1418
|
+
if (!chat)
|
|
1419
|
+
return null;
|
|
1420
|
+
const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
|
|
1421
|
+
const own = channels.filter(ch => (ch.id ?? ch.type) === actor.adapter_id);
|
|
1422
|
+
if (own.length !== 1 || own[0].type !== chat.platform || !chat.admin(actor.user_id))
|
|
1423
|
+
return null;
|
|
1424
|
+
const affected = [...store.affectedConnections(id)]; // read now: a finished change no longer has them
|
|
1425
|
+
if (!this.settingsNoticeGenerals(affected, chat.platform).length)
|
|
1426
|
+
return null;
|
|
1427
|
+
const name = this.webPerson({ adapterId: actor.adapter_id, userId: actor.user_id }).name;
|
|
1428
|
+
return {
|
|
1429
|
+
label: t("settings_confirm.web_verified_label", name),
|
|
1430
|
+
// admin() asks chat.current() too; a General a card could go to must still be there (else the host CLI's).
|
|
1431
|
+
current: () => !!verified() && chat.admin(actor.user_id) && this.settingsNoticeGenerals(affected, chat.platform).length > 0,
|
|
1432
|
+
done: view => this.settingsWebConfirmedNotice(view, affected, name, chat.platform),
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
/** #2170: the Generals a chat card could be posted in (promptSettingsChange's rule): connected, on the platform, unaffected. */
|
|
1436
|
+
settingsNoticeGenerals(affected, platform) {
|
|
1437
|
+
const excluded = new Set(affected), found = [];
|
|
1438
|
+
for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
1439
|
+
if (!config.general_topic || !this.daemons.get(name))
|
|
1440
|
+
continue;
|
|
1441
|
+
const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
|
|
1442
|
+
if (!adapterId || !adapter || excluded.has(adapterId) || adapter.type !== platform || this.adapterState.get(adapterId)?.status !== "connected")
|
|
1443
|
+
continue;
|
|
1444
|
+
const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
|
|
1445
|
+
if (chatId && topic)
|
|
1446
|
+
found.push({ adapterId, adapter, chatId, topic });
|
|
1447
|
+
}
|
|
1448
|
+
return found;
|
|
1449
|
+
}
|
|
1450
|
+
/**
|
|
1451
|
+
* #2170: General's notice of a change confirmed from the web — the first General a chat card could have been posted
|
|
1452
|
+
* in, plain text, no mentions. Information only: nothing waits on it.
|
|
1453
|
+
*/
|
|
1454
|
+
async settingsWebConfirmedNotice(view, affected, who, platform) {
|
|
1455
|
+
const outcome = view.outcome ? view.outcome.message : view.state;
|
|
1456
|
+
const text = [t("settings_confirm.web_confirmed", who), t("settings_confirm.source", settingsSourceLabel(view.source)),
|
|
1457
|
+
t("settings_confirm.requester", view.requested_by), ...view.summary, t("settings_confirm.outcome", outcome)].join("\n");
|
|
1458
|
+
for (const { adapterId, adapter, chatId, topic } of this.settingsNoticeGenerals(affected, platform)) {
|
|
1459
|
+
try {
|
|
1460
|
+
await adapter.sendText(chatId, text, { threadId: topic, allowedMentions: { parse: [] } });
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
catch {
|
|
1464
|
+
this.logger.info({ id: view.id, adapterId }, "Settings web-confirmation notice unavailable; trying another General");
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
this.logger.info({ id: view.id }, "Settings change confirmed from the web: no General reachable for the notice");
|
|
1468
|
+
}
|
|
1262
1469
|
/** Exact configured effect owners; this does not depend on prompt location. */
|
|
1263
1470
|
settingsChatAuthority(store, id) {
|
|
1264
1471
|
const scope = store.authorityOf(id);
|
|
@@ -1329,9 +1536,10 @@ export class FleetManager {
|
|
|
1329
1536
|
try {
|
|
1330
1537
|
const posted = this.postNonceButtonPromptOrThrow({ prefix: "settings-confirm:", alertType: "clear_confirm",
|
|
1331
1538
|
instanceName: name, adapterId, adapter, chatId, threadId: topic,
|
|
1332
|
-
message:
|
|
1333
|
-
|
|
1334
|
-
|
|
1539
|
+
message: [t("settings_confirm.prompt_title"), t("settings_confirm.source", settingsSourceLabel(view.source)),
|
|
1540
|
+
t("settings_confirm.requester", view.requested_by), ...view.summary].join("\n"),
|
|
1541
|
+
choices: [{ action: "confirm", label: t("settings_confirm.confirm_button") }, { action: "reject", label: t("settings_confirm.reject_button") }],
|
|
1542
|
+
expiredText: t("settings_confirm.no_longer_pending"), timeoutMs: view.remaining_ms,
|
|
1335
1543
|
extra: { pendingChangeId: view.id, confirmationCurrent: current, confirmationAdmin: userId => current() && authority.admin(userId) },
|
|
1336
1544
|
});
|
|
1337
1545
|
void posted.then(nonce => {
|
|
@@ -1342,7 +1550,7 @@ export class FleetManager {
|
|
|
1342
1550
|
return;
|
|
1343
1551
|
this.pendingNonceButtons.delete(nonce);
|
|
1344
1552
|
clearTimeout(entry.timer);
|
|
1345
|
-
void this.retireNonceButtons(entry, entry.messageId ?? "", "
|
|
1553
|
+
void this.retireNonceButtons(entry, entry.messageId ?? "", t("settings_confirm.delivery_expired"));
|
|
1346
1554
|
}, () => { });
|
|
1347
1555
|
const nonce = await withinBudget(posted, postingDeadline);
|
|
1348
1556
|
attached = true;
|
|
@@ -1352,7 +1560,7 @@ export class FleetManager {
|
|
|
1352
1560
|
return;
|
|
1353
1561
|
this.pendingNonceButtons.delete(nonce);
|
|
1354
1562
|
clearTimeout(entry.timer);
|
|
1355
|
-
void this.retireNonceButtons(entry, entry.messageId ?? "", "
|
|
1563
|
+
void this.retireNonceButtons(entry, entry.messageId ?? "", t("settings_confirm.no_longer_pending"));
|
|
1356
1564
|
};
|
|
1357
1565
|
if (!current()) {
|
|
1358
1566
|
retire();
|
|
@@ -1378,14 +1586,14 @@ export class FleetManager {
|
|
|
1378
1586
|
const { entry, action } = result;
|
|
1379
1587
|
try {
|
|
1380
1588
|
const view = await this.settingsGate().store.decide(entry.pendingChangeId, action, {
|
|
1381
|
-
label:
|
|
1589
|
+
label: t("settings_confirm.admin_label", data.userId ?? ""),
|
|
1382
1590
|
current: () => entry.confirmationCurrent?.() === true && this.getAdapterForInstance(entry.instanceName) === entry.adapter
|
|
1383
1591
|
&& !!data.userId && entry.confirmationAdmin?.(data.userId) === true,
|
|
1384
1592
|
});
|
|
1385
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, view.outcome?.message ?? "
|
|
1593
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, view.outcome?.message ?? t("settings_confirm.applying"));
|
|
1386
1594
|
}
|
|
1387
1595
|
catch {
|
|
1388
|
-
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, "
|
|
1596
|
+
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("settings_confirm.rejected_or_expired"));
|
|
1389
1597
|
}
|
|
1390
1598
|
return true;
|
|
1391
1599
|
}
|
|
@@ -1464,6 +1672,7 @@ export class FleetManager {
|
|
|
1464
1672
|
this.spawnGate = new SpawnGate({
|
|
1465
1673
|
storm: this.stormWindow,
|
|
1466
1674
|
memoryPressure: this.memoryPressure,
|
|
1675
|
+
onStartupMemoryWait: () => this.startupMemoryDeadline?.check(),
|
|
1467
1676
|
concurrency: () => this.spawnConcurrency(),
|
|
1468
1677
|
staggerMs: () => this.fleetConfig?.defaults?.startup?.stagger_delay_ms ?? 500,
|
|
1469
1678
|
});
|
|
@@ -1654,7 +1863,10 @@ export class FleetManager {
|
|
|
1654
1863
|
: t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
|
|
1655
1864
|
this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state,
|
|
1656
1865
|
cause: row.lastError, ...(silent ? { silentSchedule: true } : {}) }, notice);
|
|
1657
|
-
|
|
1866
|
+
// A self-scheduled silent heartbeat stays log-only, including the
|
|
1867
|
+
// operator alert; another source still receives the normal notice.
|
|
1868
|
+
if (!silent || row.sourceInstance !== row.targetInstance)
|
|
1869
|
+
this.notifyFleetError(notice);
|
|
1658
1870
|
}
|
|
1659
1871
|
}
|
|
1660
1872
|
});
|
|
@@ -1693,16 +1905,27 @@ export class FleetManager {
|
|
|
1693
1905
|
}
|
|
1694
1906
|
/** #1918: cross-instance traffic, as the org canvas draws it (fleet-traffic.ts). */
|
|
1695
1907
|
trafficLog = new TrafficLog();
|
|
1908
|
+
delegations = new Delegations();
|
|
1696
1909
|
trafficMemo = null;
|
|
1697
|
-
/**
|
|
1910
|
+
/**
|
|
1911
|
+
* One delivery row's traffic item, kept for the snapshot and sent to the open pages when it is news; and the
|
|
1912
|
+
* delegation it opens or closes (a task and its report), sent as "delegation".
|
|
1913
|
+
*/
|
|
1698
1914
|
publishTraffic(row, state) {
|
|
1699
1915
|
try {
|
|
1700
1916
|
const item = trafficItem(row, this.fleetConfig, new Date().toISOString(), state);
|
|
1701
1917
|
const news = item ? this.trafficLog.note(item) : null;
|
|
1702
|
-
|
|
1918
|
+
const work = news ? this.delegations.note(row, news) : [];
|
|
1919
|
+
if (news || work.length)
|
|
1703
1920
|
this.trafficMemo = null;
|
|
1921
|
+
if (news)
|
|
1704
1922
|
this.emitSseEvent("traffic", news);
|
|
1705
|
-
|
|
1923
|
+
// Each delegation as its two instances are seen now: one that expires or is pushed out may be older than a change
|
|
1924
|
+
// to their visibility (the incoming row's own item was checked; these can be anyone's).
|
|
1925
|
+
const visible = (name) => crossInstanceVisibility(this.fleetConfig, name) !== "hidden";
|
|
1926
|
+
for (const d of work)
|
|
1927
|
+
if (visible(d.from) && visible(d.to))
|
|
1928
|
+
this.emitSseEvent("delegation", d);
|
|
1706
1929
|
}
|
|
1707
1930
|
catch (err) {
|
|
1708
1931
|
this.logger.debug({ err }, "traffic: an item was not published");
|
|
@@ -1722,7 +1945,8 @@ export class FleetManager {
|
|
|
1722
1945
|
.map((r) => trafficItem(r, this.fleetConfig, r.createdAt ?? now)).filter((i) => i !== null);
|
|
1723
1946
|
const visible = (name) => crossInstanceVisibility(this.fleetConfig, name) !== "hidden";
|
|
1724
1947
|
const items = this.trafficLog.snapshot(pending).filter((i) => visible(i.from) && visible(i.to));
|
|
1725
|
-
|
|
1948
|
+
const delegations = this.delegations.snapshot().filter((d) => visible(d.from) && visible(d.to));
|
|
1949
|
+
this.trafficMemo = { atMs: nowMs, value: { now, items, delegations } };
|
|
1726
1950
|
return this.trafficMemo.value;
|
|
1727
1951
|
}
|
|
1728
1952
|
/** Synchronous SQLite admission; the caller may acknowledge only after this returns. */
|
|
@@ -2494,6 +2718,11 @@ export class FleetManager {
|
|
|
2494
2718
|
this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
|
|
2495
2719
|
this.warnAboutRemovedLoginMode(loaded);
|
|
2496
2720
|
this.enforceGuestSwitch();
|
|
2721
|
+
this.observeWebAuthority(); // #2453 review: a reload's web-authority switches are seen at once
|
|
2722
|
+
// Child tool/guidance readers must follow this fleet's actual config, including --config.
|
|
2723
|
+
// Scope the path to its owning data directory so another inherited home's override cannot enable CW.
|
|
2724
|
+
process.env.AGEND_FLEET_DATA_DIR = resolvePath(this.dataDir);
|
|
2725
|
+
process.env.AGEND_FLEET_CONFIG_PATH = resolvePath(configPath);
|
|
2497
2726
|
return this.fleetConfig;
|
|
2498
2727
|
}
|
|
2499
2728
|
relayLoginModeWarned = false;
|
|
@@ -2673,8 +2902,17 @@ export class FleetManager {
|
|
|
2673
2902
|
this.logger.info(`Received SIGUSR1, initiating full restart (process reload)... ${describeSignalSource(this.dataDir, "SIGUSR1")}`);
|
|
2674
2903
|
this.gracefulShutdownForReload()
|
|
2675
2904
|
.then(() => {
|
|
2676
|
-
|
|
2677
|
-
|
|
2905
|
+
// #2356: under an AgEnD systemd unit a clean exit is a stop (Restart=on-failure); exit so the unit comes back.
|
|
2906
|
+
let ownCgroup = null;
|
|
2907
|
+
try {
|
|
2908
|
+
ownCgroup = readFileSync("/proc/self/cgroup", "utf-8");
|
|
2909
|
+
}
|
|
2910
|
+
catch { /* not Linux */ }
|
|
2911
|
+
const code = fullReloadExitCode(ownCgroup);
|
|
2912
|
+
this.logger.info(code === 0
|
|
2913
|
+
? "Full restart: shutdown complete, exiting for reload"
|
|
2914
|
+
: `Full restart: shutdown complete, exiting ${code} so the systemd unit restarts the fleet (#2356)`);
|
|
2915
|
+
process.exit(code);
|
|
2678
2916
|
})
|
|
2679
2917
|
.catch(err => {
|
|
2680
2918
|
this.logger.error({ err }, "Full restart: graceful shutdown failed");
|
|
@@ -2693,7 +2931,7 @@ export class FleetManager {
|
|
|
2693
2931
|
const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
|
|
2694
2932
|
this.beginUpdateProgress(adapter, chatId, data.channelId, messageId);
|
|
2695
2933
|
}
|
|
2696
|
-
const { spawn } = await import("
|
|
2934
|
+
const { spawn } = await import("./child-process.js");
|
|
2697
2935
|
// Plain `agend update`: the CLI picks the channel from the version it is about to replace (a beta install
|
|
2698
2936
|
// stays on beta). Deciding here, from the package.json next to THIS code, read a source checkout's 1.22.0 as
|
|
2699
2937
|
// "not a beta" and sent a beta install to @latest. #1450 C5: the INSTALLED agend, verified, by absolute path.
|
|
@@ -3087,7 +3325,8 @@ export class FleetManager {
|
|
|
3087
3325
|
instancePresentation(name) {
|
|
3088
3326
|
const execution_state = this.getInstanceExecutionState(name);
|
|
3089
3327
|
const interaction = this.getInstanceInteraction(name);
|
|
3090
|
-
|
|
3328
|
+
const startup_phase = !this.lifecycle.isPaused(name) ? this.startupDashboard?.phase(name) : undefined;
|
|
3329
|
+
return { ...(startup_phase ? { startup_phase } : {}), state: presentationState(execution_state, interaction), execution_state,
|
|
3091
3330
|
interaction, interaction_summary: interactionSummary(interaction) };
|
|
3092
3331
|
}
|
|
3093
3332
|
/**
|
|
@@ -3521,7 +3760,9 @@ export class FleetManager {
|
|
|
3521
3760
|
if (this.shuttingDown || !this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
|
|
3522
3761
|
return false;
|
|
3523
3762
|
if (!idle) {
|
|
3524
|
-
|
|
3763
|
+
// #2482: a busy target is the normal case (its CLI queues the message): INFO; a WARN only for a likely anomaly.
|
|
3764
|
+
const log = idleGateTimeoutLog(instanceName, timeoutMs, this.instanceStateCache.get(instanceName), Date.now());
|
|
3765
|
+
this.logger[log.level](log.fields, log.message);
|
|
3525
3766
|
}
|
|
3526
3767
|
const sent = await this.sendWhenConnected(instanceName, payload, deliveryEpoch, stillCurrent);
|
|
3527
3768
|
if (!sent)
|
|
@@ -3635,12 +3876,69 @@ export class FleetManager {
|
|
|
3635
3876
|
* #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
|
|
3636
3877
|
* to the guests' streams only: an admin's page already has it, and nothing about it changed for them.
|
|
3637
3878
|
*/
|
|
3638
|
-
webMessageInChannel(instance, messageId) {
|
|
3879
|
+
webMessageInChannel(instance, messageId, copies = []) {
|
|
3880
|
+
// #2305: the copies' own message ids, so an instance's reaction to the web message is put on them too.
|
|
3881
|
+
if (copies.length)
|
|
3882
|
+
this.rememberWebEchoCopies(messageId, copies);
|
|
3883
|
+
// #2281: and where they are, for a later web reply to this message to be a native reply there.
|
|
3884
|
+
const posts = copies.flatMap(c => c.adapterId ? [{ adapterId: c.adapterId, chatId: c.chatId, ...(c.threadId ? { threadId: c.threadId } : {}), messageId: c.messageId }] : []);
|
|
3885
|
+
if (posts.length)
|
|
3886
|
+
this.webChatHistory.addPosts(instance, messageId, posts);
|
|
3639
3887
|
const m = this.webChatHistory.markChannel(instance, messageId);
|
|
3640
3888
|
if (m)
|
|
3641
3889
|
broadcastToGuests(this.sseClients, "message", m, (err) => this.logger.debug({ err }, "SSE client write failed; evicting"));
|
|
3642
3890
|
}
|
|
3643
|
-
|
|
3891
|
+
/** #2305: a web message's channel copies (topic, ClassicBot rooms), newest WEB_ECHO_COPIES_MAX, this process only. */
|
|
3892
|
+
webEchoCopies = new Map();
|
|
3893
|
+
rememberWebEchoCopies(messageId, copies) {
|
|
3894
|
+
const list = [...(this.webEchoCopies.get(messageId) ?? []), ...copies.filter(c => c && typeof c.adapter?.react === "function" && c.messageId)];
|
|
3895
|
+
this.webEchoCopies.delete(messageId);
|
|
3896
|
+
this.webEchoCopies.set(messageId, list);
|
|
3897
|
+
while (this.webEchoCopies.size > WEB_ECHO_COPIES_MAX)
|
|
3898
|
+
this.webEchoCopies.delete(this.webEchoCopies.keys().next().value);
|
|
3899
|
+
}
|
|
3900
|
+
/**
|
|
3901
|
+
* #2305: an instance's reaction to a web chat message (`messageId`, the id it was handed): its minimal reply, as on
|
|
3902
|
+
* the platforms. Kept on the message's row (and on disk), shown live (`reactions` on the stream), and put on the
|
|
3903
|
+
* message's channel copies when this process sent them — in the background, each within WEB_MIRROR_TIMEOUT_MS, so a
|
|
3904
|
+
* slow or rate-limited platform never holds the tool's answer (best effort: a copy's failure or timeout is logged,
|
|
3905
|
+
* never the tool's). `mirrored`: how many copies took it, when that is known.
|
|
3906
|
+
* The tool's errors: an emoji outside the web set or one of the instance's delivery marks (web-reactions.ts), or a
|
|
3907
|
+
* message the chat does not have. An emoji the message already has is a success that changes nothing.
|
|
3908
|
+
*/
|
|
3909
|
+
async reactToWebMessage(instance, messageId, raw) {
|
|
3910
|
+
const problem = webReactionProblem(raw, this.statusEmojiAvoidList(instance));
|
|
3911
|
+
if (problem)
|
|
3912
|
+
return { ok: false, error: problem };
|
|
3913
|
+
const emoji = webReactionEmoji(raw);
|
|
3914
|
+
const r = this.webChatHistory.addReaction(instance, messageId, emoji);
|
|
3915
|
+
if (!r)
|
|
3916
|
+
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` };
|
|
3917
|
+
if (!r.added)
|
|
3918
|
+
return { ok: true, mirrored: Promise.resolve(0) };
|
|
3919
|
+
this.emitSseEvent("reactions", { instance, boot: r.message.boot, id: r.message.id, reactions: r.message.reactions ?? [] });
|
|
3920
|
+
const mirrorOne = (copy) => {
|
|
3921
|
+
let timer;
|
|
3922
|
+
const timeout = new Promise((_, reject) => {
|
|
3923
|
+
timer = setTimeout(() => reject(new Error(`no answer in ${WEB_MIRROR_TIMEOUT_MS} ms`)), WEB_MIRROR_TIMEOUT_MS);
|
|
3924
|
+
timer.unref?.();
|
|
3925
|
+
});
|
|
3926
|
+
return Promise.race([Promise.resolve().then(() => copy.adapter.react(copy.chatId, copy.messageId, reactionForm(copy.adapter.type, emoji), copy.threadId)), timeout])
|
|
3927
|
+
.then(() => 1, (err) => {
|
|
3928
|
+
this.logger.warn({ err, instance, adapter: copy.adapter.type }, "Reaction on a web message's channel copy failed (shown on the web)");
|
|
3929
|
+
return 0;
|
|
3930
|
+
})
|
|
3931
|
+
.finally(() => clearTimeout(timer));
|
|
3932
|
+
};
|
|
3933
|
+
const mirrored = Promise.all((this.webEchoCopies.get(messageId) ?? []).map(mirrorOne)).then(n => n.reduce((a, b) => a + b, 0));
|
|
3934
|
+
return { ok: true, mirrored };
|
|
3935
|
+
}
|
|
3936
|
+
/** #2305: `copies` collects each room's copy (its message id), for a reaction to the web message. */
|
|
3937
|
+
/**
|
|
3938
|
+
* `reply` (#2281): where the message a web reply quotes is on a platform. A room that has it gets the reply as a
|
|
3939
|
+
* native reply to it (`reply.text`: without the quoted line); every other room gets `text`, as before.
|
|
3940
|
+
*/
|
|
3941
|
+
async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK, copies, reply) {
|
|
3644
3942
|
const guard = this.webChannelEchoGuards.get(instance);
|
|
3645
3943
|
const handled = new Set();
|
|
3646
3944
|
let posted = 0;
|
|
@@ -3670,7 +3968,11 @@ export class FleetManager {
|
|
|
3670
3968
|
try {
|
|
3671
3969
|
if (!adapter?.sendText)
|
|
3672
3970
|
continue;
|
|
3673
|
-
|
|
3971
|
+
// A Telegram room is its chat; a Discord room is the guild's channel (an inbound's thread there).
|
|
3972
|
+
const native = reply?.posts.find(p => p.adapterId === adapter.id && (p.chatId === entry.channelId || p.threadId === entry.channelId));
|
|
3973
|
+
const sent = await adapter.sendText(entry.channelId, native ? reply.text : text, { format: "text", allowedMentions: { parse: [] }, ...(native ? { replyTo: native.messageId } : {}) });
|
|
3974
|
+
if (copies && sent && typeof sent.messageId === "string" && sent.messageId)
|
|
3975
|
+
copies.push({ adapter, adapterId: adapter.id, chatId: entry.channelId, messageId: sent.messageId });
|
|
3674
3976
|
ClassicChannelManager.logMessage(instance, sender, text, new Date());
|
|
3675
3977
|
posted++;
|
|
3676
3978
|
}
|
|
@@ -4088,6 +4390,18 @@ export class FleetManager {
|
|
|
4088
4390
|
async dispatchSlashCore(slash, adapterId, adapter) {
|
|
4089
4391
|
// #1569: an alias (/web) is its command (/dashboard) from here on — the door, the table and the handler see one name.
|
|
4090
4392
|
const data = { ...slash, command: canonicalCommand(slash.command) };
|
|
4393
|
+
// #2359: /dashboard stays listed; prefix its reply with the deprecation notice, then run /web's handler.
|
|
4394
|
+
const noteDeprecatedDashboard = () => {
|
|
4395
|
+
if (slash.command !== "dashboard")
|
|
4396
|
+
return;
|
|
4397
|
+
const prefix = `${t("dashboard.deprecated")}\n\n`;
|
|
4398
|
+
const respond = data.respond;
|
|
4399
|
+
data.respond = async (text) => respond(prefix + text);
|
|
4400
|
+
if (data.respondButtons) {
|
|
4401
|
+
const respondButtons = data.respondButtons;
|
|
4402
|
+
data.respondButtons = async (text, choices) => respondButtons(prefix + text, choices);
|
|
4403
|
+
}
|
|
4404
|
+
};
|
|
4091
4405
|
const door = this.slashDoor(data, adapterId);
|
|
4092
4406
|
if ("refusal" in door) {
|
|
4093
4407
|
await data.respond(t(door.refusal)).catch(() => { });
|
|
@@ -4098,6 +4412,7 @@ export class FleetManager {
|
|
|
4098
4412
|
// — before the table, which keeps /web for fleet admins. Per account: it touches no instance binding.
|
|
4099
4413
|
// Decided synchronously, so an admin's /web still reaches the table with no await after the door (#1399).
|
|
4100
4414
|
if (data.command === "dashboard" && this.isGuestWebAsk(adapterId, data.userId)) {
|
|
4415
|
+
noteDeprecatedDashboard();
|
|
4101
4416
|
await this.guestWebCommand({ adapterId, userId: data.userId, username: data.username,
|
|
4102
4417
|
arg: String(data.options?.action ?? "").trim().toLowerCase(), replyHere: text => data.respond(text), replyPrivately: text => data.respond(text) });
|
|
4103
4418
|
return;
|
|
@@ -4126,6 +4441,7 @@ export class FleetManager {
|
|
|
4126
4441
|
await data.respond(reply).catch(() => { });
|
|
4127
4442
|
return;
|
|
4128
4443
|
}
|
|
4444
|
+
noteDeprecatedDashboard();
|
|
4129
4445
|
if (data.command === "start") {
|
|
4130
4446
|
await this.handleClassicStartSlash(data, adapterId);
|
|
4131
4447
|
}
|
|
@@ -4694,7 +5010,7 @@ export class FleetManager {
|
|
|
4694
5010
|
return;
|
|
4695
5011
|
}
|
|
4696
5012
|
if (this.lifecycle.isPaused(name)) {
|
|
4697
|
-
this.logger.
|
|
5013
|
+
this.logger.debug({ name }, "Persisted paused instance — skipping startup"); // #2482: every start and reload
|
|
4698
5014
|
return;
|
|
4699
5015
|
}
|
|
4700
5016
|
if (this.lifecycle.daemons.has(name)) {
|
|
@@ -4713,50 +5029,92 @@ export class FleetManager {
|
|
|
4713
5029
|
this.logger.info({ name }, "Instance already running, skipping");
|
|
4714
5030
|
return;
|
|
4715
5031
|
}
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
|
|
4723
|
-
|
|
4724
|
-
else if (kind === "fleet-topic") {
|
|
4725
|
-
// Workers get only role-eligible on-demand skills. Classic instances are
|
|
4726
|
-
// deliberately excluded: their workspace and conversation lifecycle are
|
|
4727
|
-
// managed independently from fleet-topic workers.
|
|
4728
|
-
try {
|
|
4729
|
-
const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
|
|
4730
|
-
measureSyncWork("fleet.workerSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "worker"));
|
|
5032
|
+
await this.trackStartupInstance(name, async () => {
|
|
5033
|
+
// An explicit start (CLI/API) begins a fresh idle window; boot and
|
|
5034
|
+
// reconcile starts keep the persisted last-inbound seed (Phase 2a).
|
|
5035
|
+
if (resumePaused)
|
|
5036
|
+
this.lifecycle.markActivitySeedNow(name);
|
|
5037
|
+
const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
5038
|
+
if (config.general_topic) {
|
|
5039
|
+
this.ensureGeneralInstructions(config.working_directory, backend, name);
|
|
4731
5040
|
}
|
|
4732
|
-
|
|
4733
|
-
//
|
|
4734
|
-
//
|
|
4735
|
-
// fleet
|
|
4736
|
-
|
|
5041
|
+
else if (kind === "fleet-topic") {
|
|
5042
|
+
// Workers get only role-eligible on-demand skills. Classic instances are
|
|
5043
|
+
// deliberately excluded: their workspace and conversation lifecycle are
|
|
5044
|
+
// managed independently from fleet-topic workers.
|
|
5045
|
+
try {
|
|
5046
|
+
const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
|
|
5047
|
+
measureSyncWork("fleet.workerSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "worker"));
|
|
5048
|
+
}
|
|
5049
|
+
catch (err) {
|
|
5050
|
+
// Skill publishing is additive. A read-only or temporarily unavailable
|
|
5051
|
+
// workspace must not turn an otherwise valid worker startup into a
|
|
5052
|
+
// fleet outage.
|
|
5053
|
+
this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
|
|
5054
|
+
}
|
|
4737
5055
|
}
|
|
5056
|
+
else if (kind === "classic") {
|
|
5057
|
+
// #2163: a Classic bot gets only the skills marked `classic` (its own look, the dashboard) — never the fleet's
|
|
5058
|
+
// coordination playbooks (#557 kept those out of end-user bots) — and only into a workspace AgEnD manages: the
|
|
5059
|
+
// room's own folder under <AGEND_HOME>/workspaces/. Anywhere else, nothing is written; `agend-agent skill <name>`
|
|
5060
|
+
// reads any bundled skill on demand.
|
|
5061
|
+
try {
|
|
5062
|
+
const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
|
|
5063
|
+
if (insideManagedWorkspaces(skillsWorkDir))
|
|
5064
|
+
measureSyncWork("fleet.classicSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "classic"));
|
|
5065
|
+
else
|
|
5066
|
+
this.logger.debug({ name, skillsWorkDir }, "Classic workspace is not AgEnD-managed — no skills published");
|
|
5067
|
+
}
|
|
5068
|
+
catch (err) {
|
|
5069
|
+
this.logger.warn({ err, name, backend }, "Failed to sync classic skills — continuing startup");
|
|
5070
|
+
}
|
|
5071
|
+
}
|
|
5072
|
+
const receipt = execution ? this.classicSettingsOwners.get(execution) : undefined;
|
|
5073
|
+
const identity = { kind, backend, model: this.resolveInstanceModel(name).display };
|
|
5074
|
+
if (receipt) {
|
|
5075
|
+
await this.lifecycle.start(name, config, topicMode, identity, transition, execution, daemon => {
|
|
5076
|
+
// Only this transition's actual publication can acquire its replacement.
|
|
5077
|
+
receipt.daemon = daemon;
|
|
5078
|
+
});
|
|
5079
|
+
}
|
|
5080
|
+
else
|
|
5081
|
+
await this.lifecycle.start(name, config, topicMode, identity, transition, execution);
|
|
5082
|
+
execution?.assert();
|
|
5083
|
+
// Only clear a stale process status after a real start succeeded. Clearing
|
|
5084
|
+
// it before lifecycle.start() can turn a crash-loop daemon's dead pane into
|
|
5085
|
+
// a falsely running instance when lifecycle.start() returns early.
|
|
5086
|
+
this.instanceProcessStatus.delete(name);
|
|
5087
|
+
// Recovery intent belongs to Daemon.start. lifecycle.start can return
|
|
5088
|
+
// before reaching it, so an await here is not proof the marker was read.
|
|
5089
|
+
// Auto-connect IPC — daemon.start() ensures socket is ready before resolving
|
|
5090
|
+
await this.connectIpcToInstance(name);
|
|
5091
|
+
execution?.assert();
|
|
5092
|
+
this.requestDiscordUsagePresenceRefresh();
|
|
5093
|
+
});
|
|
5094
|
+
}
|
|
5095
|
+
/** A boot's observation cannot settle a replacement boot or a newer attempt of the same instance. */
|
|
5096
|
+
async trackStartupInstance(name, start) {
|
|
5097
|
+
const boot = this.startupDashboard;
|
|
5098
|
+
const owner = boot?.begin(name);
|
|
5099
|
+
if (owner)
|
|
5100
|
+
this.publishStartupStatus();
|
|
5101
|
+
let ready = false;
|
|
5102
|
+
try {
|
|
5103
|
+
await start();
|
|
5104
|
+
ready = this.daemons.has(name);
|
|
4738
5105
|
}
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
5106
|
+
finally {
|
|
5107
|
+
if (boot && owner && this.startupDashboard === boot && boot.finish(name, owner, ready))
|
|
5108
|
+
this.publishStartupStatus();
|
|
5109
|
+
}
|
|
5110
|
+
}
|
|
5111
|
+
publishStartupStatus() {
|
|
5112
|
+
try {
|
|
5113
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
5114
|
+
}
|
|
5115
|
+
catch (err) {
|
|
5116
|
+
this.logger.debug({ err }, "Startup status unavailable; the next status frame retries");
|
|
4746
5117
|
}
|
|
4747
|
-
else
|
|
4748
|
-
await this.lifecycle.start(name, config, topicMode, identity, transition, execution);
|
|
4749
|
-
execution?.assert();
|
|
4750
|
-
// Only clear a stale process status after a real start succeeded. Clearing
|
|
4751
|
-
// it before lifecycle.start() can turn a crash-loop daemon's dead pane into
|
|
4752
|
-
// a falsely running instance when lifecycle.start() returns early.
|
|
4753
|
-
this.instanceProcessStatus.delete(name);
|
|
4754
|
-
// Recovery intent belongs to Daemon.start. lifecycle.start can return
|
|
4755
|
-
// before reaching it, so an await here is not proof the marker was read.
|
|
4756
|
-
// Auto-connect IPC — daemon.start() ensures socket is ready before resolving
|
|
4757
|
-
await this.connectIpcToInstance(name);
|
|
4758
|
-
execution?.assert();
|
|
4759
|
-
this.requestDiscordUsagePresenceRefresh();
|
|
4760
5118
|
}
|
|
4761
5119
|
/** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
|
|
4762
5120
|
async startPersistedPausedInstance(name, transition) {
|
|
@@ -4774,8 +5132,8 @@ export class FleetManager {
|
|
|
4774
5132
|
}
|
|
4775
5133
|
/**
|
|
4776
5134
|
* Start instances with configurable concurrency and stagger delay.
|
|
4777
|
-
* Instances sharing the same working_directory are serialized
|
|
4778
|
-
*
|
|
5135
|
+
* Instances sharing the same working_directory are serialized to avoid
|
|
5136
|
+
* config file races. Stagger applies between outer lifecycle admissions.
|
|
4779
5137
|
* TODO: per-instance startup timeout (existing issue, not introduced here)
|
|
4780
5138
|
*/
|
|
4781
5139
|
async startInstancesWithConcurrency(entries, topicMode, onReady) {
|
|
@@ -4797,15 +5155,30 @@ export class FleetManager {
|
|
|
4797
5155
|
totalInstances: runnableEntries.length,
|
|
4798
5156
|
}, "Adaptive startup concurrency");
|
|
4799
5157
|
}
|
|
5158
|
+
const startedAt = performance.now();
|
|
5159
|
+
let active = 0, peakConcurrentStarts = 0, readyInstances = 0;
|
|
4800
5160
|
await Promise.all(runnableEntries.map(([name, config]) => this.spawnGate.run({
|
|
4801
5161
|
instanceName: name,
|
|
4802
5162
|
workingDirectory: config.working_directory,
|
|
4803
5163
|
reason: "startup",
|
|
4804
5164
|
stage: "lifecycle",
|
|
4805
5165
|
}, async () => {
|
|
4806
|
-
|
|
4807
|
-
|
|
5166
|
+
peakConcurrentStarts = Math.max(peakConcurrentStarts, ++active);
|
|
5167
|
+
try {
|
|
5168
|
+
if (await this.startInstanceUnattended(name, config, topicMode, "instance")) {
|
|
5169
|
+
readyInstances++;
|
|
5170
|
+
onReady?.(name);
|
|
5171
|
+
}
|
|
5172
|
+
}
|
|
5173
|
+
finally {
|
|
5174
|
+
active--;
|
|
5175
|
+
}
|
|
4808
5176
|
})));
|
|
5177
|
+
this.logger.info({
|
|
5178
|
+
totalInstances: runnableEntries.length, readyInstances,
|
|
5179
|
+
elapsedSeconds: Math.round((performance.now() - startedAt) / 100) / 10,
|
|
5180
|
+
peakConcurrentStarts,
|
|
5181
|
+
}, "Fleet startup batch completed");
|
|
4809
5182
|
}
|
|
4810
5183
|
/**
|
|
4811
5184
|
* Start from an UNATTENDED path (fleet startup, full restart, config
|
|
@@ -5231,34 +5604,18 @@ export class FleetManager {
|
|
|
5231
5604
|
return this.adapterState.get(adapterId)?.status === "connected" ? adapter : undefined;
|
|
5232
5605
|
}
|
|
5233
5606
|
/** Last-resort completion after RestartProgress could not deliver to its
|
|
5234
|
-
* adopted target
|
|
5607
|
+
* adopted target (or had no message: a small fleet, a failed first post). It
|
|
5608
|
+
* never wakes a not-ready gateway. #2213: an outage at fleet start no longer
|
|
5609
|
+
* loses it — bounded at startup, then retried in the background once the
|
|
5610
|
+
* adapter is ready again, never posted twice after an uncertain attempt. */
|
|
5235
5611
|
async sendFleetStartCompletionFallback(chatId, text, threadId) {
|
|
5236
5612
|
const adapterId = this.getPrimaryAdapterId();
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
this.logger.error({ adapterId }, "Fleet start completion fallback skipped because the primary adapter is not ready");
|
|
5613
|
+
if (!adapterId) {
|
|
5614
|
+
this.logger.error({ adapterId }, "Fleet start completion fallback skipped because there is no primary adapter");
|
|
5240
5615
|
return false;
|
|
5241
5616
|
}
|
|
5242
|
-
|
|
5243
|
-
|
|
5244
|
-
timer = setTimeout(() => resolve({ status: "timeout" }), RESTART_PROGRESS_TERMINAL_TIMEOUT_MS);
|
|
5245
|
-
timer.unref?.();
|
|
5246
|
-
});
|
|
5247
|
-
const delivery = Promise.resolve()
|
|
5248
|
-
.then(() => adapter.sendText(chatId, text, { threadId }))
|
|
5249
|
-
.then(() => ({ status: "sent" }), err => ({ status: "failed", err }));
|
|
5250
|
-
const result = await Promise.race([delivery, timeout]);
|
|
5251
|
-
if (timer)
|
|
5252
|
-
clearTimeout(timer);
|
|
5253
|
-
if (result.status === "sent")
|
|
5254
|
-
return true;
|
|
5255
|
-
if (result.status === "failed") {
|
|
5256
|
-
this.logger.error({ err: result.err }, "Failed to send fleet start completion fallback");
|
|
5257
|
-
}
|
|
5258
|
-
else {
|
|
5259
|
-
this.logger.error({ timeout_ms: RESTART_PROGRESS_TERMINAL_TIMEOUT_MS }, "Timed out sending fleet start completion fallback");
|
|
5260
|
-
}
|
|
5261
|
-
return false;
|
|
5617
|
+
const poster = new RestartProgress(0, Date.now(), this.logger);
|
|
5618
|
+
return poster.postCompletion({ resolveAdapter: () => this.readyProgressAdapter(adapterId), chatId, threadId }, text);
|
|
5262
5619
|
}
|
|
5263
5620
|
async stopInstance(name, transition) {
|
|
5264
5621
|
this.explicitStopGeneration.set(name, (this.explicitStopGeneration.get(name) ?? 0) + 1);
|
|
@@ -5694,11 +6051,16 @@ export class FleetManager {
|
|
|
5694
6051
|
dataDir: this.dataDir, web: () => this.fleetConfig?.web,
|
|
5695
6052
|
permitted: owner => this.publicOwnerCurrent(owner),
|
|
5696
6053
|
reserve: id => this.getTunnelLane().reserve("dashboard", id),
|
|
5697
|
-
createGateway: (exposureId, current, open, failed) => createPublicWebGateway({ exposureId, isCurrent: current, isOpen: open, onError: failed,
|
|
6054
|
+
createGateway: (exposureId, current, open, failed, named) => createPublicWebGateway({ ...named, exposureId, isCurrent: current, isOpen: open, onError: failed,
|
|
5698
6055
|
dispatch: (req, res) => this.dispatchWebHttp(req, res, this.fleetConfig?.health_port ?? 19280) }),
|
|
5699
6056
|
revoke: id => {
|
|
5700
6057
|
this.webLoginCodes?.revokeAudience(id);
|
|
5701
6058
|
this.webGuestLoginCodes?.revokeAudience(id);
|
|
6059
|
+
// #2453 (§0.2): the link's sessions end, and the authorizations kept for them go with them (housekeeping: their
|
|
6060
|
+
// handles can never match again).
|
|
6061
|
+
const ended = new Set(this.webSessions?.list(undefined, r => r.exposureId === id).map(r => r.handle) ?? []);
|
|
6062
|
+
if (ended.size)
|
|
6063
|
+
this.sessionAuthorizationStore()?.prune(a => !ended.has(a.handle));
|
|
5702
6064
|
const result = this.webSessions?.revokeExposure(id);
|
|
5703
6065
|
if (result && !result.durable)
|
|
5704
6066
|
this.logger.warn("Public session revocation could not be saved; exposure remains closed");
|
|
@@ -5719,6 +6081,66 @@ export class FleetManager {
|
|
|
5719
6081
|
},
|
|
5720
6082
|
});
|
|
5721
6083
|
}
|
|
6084
|
+
publicLinkAutoOpen = null;
|
|
6085
|
+
/**
|
|
6086
|
+
* #named-link-ttl: the admin who last opened a named-tunnel link from /web, with the live binding — only while they
|
|
6087
|
+
* still may own one (publicOwnerCurrent: a fleet admin of a live bot, the same General). Else null.
|
|
6088
|
+
*/
|
|
6089
|
+
rememberedPublicOwner() {
|
|
6090
|
+
const saved = readPublicLinkOwner(this.dataDir);
|
|
6091
|
+
if (!saved)
|
|
6092
|
+
return null;
|
|
6093
|
+
const owner = { ...saved, binding: this.adapters.get(saved.adapterId) };
|
|
6094
|
+
return this.publicOwnerCurrent(owner) ? owner : null;
|
|
6095
|
+
}
|
|
6096
|
+
/** A named-tunnel link was privately delivered: auto_open reopens it for this owner after a restart. */
|
|
6097
|
+
rememberPublicLinkOwner(owner) {
|
|
6098
|
+
if (!publicLinkNamedHostname(this.fleetConfig?.web))
|
|
6099
|
+
return;
|
|
6100
|
+
try {
|
|
6101
|
+
writePublicLinkOwner(this.dataDir, owner);
|
|
6102
|
+
}
|
|
6103
|
+
catch (err) {
|
|
6104
|
+
this.logger.warn({ err: err?.message }, "Could not remember the public link's owner; auto_open will not reopen it");
|
|
6105
|
+
}
|
|
6106
|
+
}
|
|
6107
|
+
/** Closed on purpose (Close, /web revoke): a restart must not reopen it. The next /web remembers again. */
|
|
6108
|
+
forgetPublicLinkOwner() {
|
|
6109
|
+
try {
|
|
6110
|
+
clearPublicLinkOwner(this.dataDir);
|
|
6111
|
+
}
|
|
6112
|
+
catch (err) {
|
|
6113
|
+
this.logger.warn({ err: err?.message }, "Could not forget the public link's owner");
|
|
6114
|
+
}
|
|
6115
|
+
}
|
|
6116
|
+
/**
|
|
6117
|
+
* #named-link-ttl: `web.public_link.auto_open` — once per start, after the web listener and the chat connections are
|
|
6118
|
+
* up. Opens the named link with nothing posted (sign-in still needs a code from /web or a returning device); a final
|
|
6119
|
+
* failure goes to its owner once, by DM, drawn as a failed /web is.
|
|
6120
|
+
*/
|
|
6121
|
+
startPublicLinkAutoOpen() {
|
|
6122
|
+
this.publicLinkAutoOpen?.stop();
|
|
6123
|
+
const runner = this.publicLinkAutoOpen = new PublicLinkAutoOpen({
|
|
6124
|
+
web: () => this.fleetConfig?.web,
|
|
6125
|
+
ready: () => !this.shuttingDown && this.healthServerListening && this.publicLinkAutoOpen === runner,
|
|
6126
|
+
owner: () => this.rememberedPublicOwner(),
|
|
6127
|
+
open: (owner, onProgress) => this.getPublicWebLink().openUnattended(owner, onProgress),
|
|
6128
|
+
notifyFailed: async (owner, last) => {
|
|
6129
|
+
const adapter = owner.binding;
|
|
6130
|
+
if (this.shuttingDown || !adapter?.sendDirect || !this.publicOwnerCurrent(owner))
|
|
6131
|
+
return;
|
|
6132
|
+
const outcome = t("dashboard.public_auto_failed");
|
|
6133
|
+
try {
|
|
6134
|
+
await withinBudget(adapter.sendDirect(owner.userId, last ? renderPublicLinkProgress(last, performance.now(), outcome) : outcome, { disablePreview: true }), performance.now() + 5_000);
|
|
6135
|
+
}
|
|
6136
|
+
catch (err) {
|
|
6137
|
+
this.logDashboardDmFailure(err);
|
|
6138
|
+
}
|
|
6139
|
+
},
|
|
6140
|
+
log: (event, fields) => this.logger.info({ event, ...fields }, "Public web link"),
|
|
6141
|
+
});
|
|
6142
|
+
runner.start();
|
|
6143
|
+
}
|
|
5722
6144
|
getPublicWebStatus() {
|
|
5723
6145
|
return this.publicWebLink?.status() ?? { state: "closed" };
|
|
5724
6146
|
}
|
|
@@ -5902,8 +6324,9 @@ export class FleetManager {
|
|
|
5902
6324
|
// Show the action before any link exists. TG's Start instructions belong to confirmed DM refusal below.
|
|
5903
6325
|
const menuText = this.topicCommands.getDashboardMenuText()
|
|
5904
6326
|
+ (publicAllowed ? "\n" + t("dashboard.public_risk") : "")
|
|
5905
|
-
+ (status.state
|
|
5906
|
-
|
|
6327
|
+
+ (status.state === "closed" ? "" : status.noExpiry ? "\n" + t("dashboard.public_status_no_expiry", status.state)
|
|
6328
|
+
: "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
|
|
6329
|
+
+ (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : ""));
|
|
5907
6330
|
const choices = [{ action: "local", label: t("dashboard.local_signin") },
|
|
5908
6331
|
...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
|
|
5909
6332
|
...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
|
|
@@ -5950,6 +6373,7 @@ export class FleetManager {
|
|
|
5950
6373
|
if (!entry.publicExposureId)
|
|
5951
6374
|
return true;
|
|
5952
6375
|
const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
|
|
6376
|
+
this.forgetPublicLinkOwner();
|
|
5953
6377
|
if (result?.confirmed === false)
|
|
5954
6378
|
data.ack?.(t("dashboard.public_cleanup"));
|
|
5955
6379
|
await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"), data.editClicked);
|
|
@@ -5980,10 +6404,11 @@ export class FleetManager {
|
|
|
5980
6404
|
return false;
|
|
5981
6405
|
this.initializeWebSessions();
|
|
5982
6406
|
const issued = this.webLoginCodes.issue({ epoch: tokenEpoch(token), audience: exposureId, owner });
|
|
5983
|
-
// Only a public link has a link expiry; the code's five-minute
|
|
6407
|
+
// Only a public link has a link expiry (null: none — a named tunnel's ttl_minutes 0); the code's five-minute
|
|
6408
|
+
// lifetime applies to either packet.
|
|
5984
6409
|
const minutes = Math.round(LOGIN_CODE_TTL_MS / 60_000);
|
|
5985
6410
|
const text = expiresAt !== undefined
|
|
5986
|
-
? t("dashboard.private_public", url, issued.display, minutes, formatFleetTime(expiresAt))
|
|
6411
|
+
? t("dashboard.private_public", url, issued.display, minutes, expiresAt === null ? t("dashboard.public_no_expiry") : formatFleetTime(expiresAt))
|
|
5987
6412
|
: t("dashboard.private_local", url, issued.display, minutes);
|
|
5988
6413
|
const deadline = performance.now() + 10_000;
|
|
5989
6414
|
const direct = owner.binding;
|
|
@@ -6046,6 +6471,8 @@ export class FleetManager {
|
|
|
6046
6471
|
// Still closes the step editor below and tells the clicker: a throw must not leave the menu refreshing.
|
|
6047
6472
|
this.logger.warn({ err }, "Dashboard sign-in delivery failed");
|
|
6048
6473
|
}
|
|
6474
|
+
if (ok && action === "public")
|
|
6475
|
+
this.rememberPublicLinkOwner(owner);
|
|
6049
6476
|
// Only safe, static words enter General; never link, code or platform error text.
|
|
6050
6477
|
// #1742: Telegram's failure walks through opening the DM (never a bare /start, which starts a ClassicBot agent),
|
|
6051
6478
|
// with the "先開私訊 →" button when the bot's username is known; and for a while a bare /start there counts as
|
|
@@ -6070,6 +6497,7 @@ export class FleetManager {
|
|
|
6070
6497
|
revokeWebSessions() {
|
|
6071
6498
|
this.initializeWebSessions();
|
|
6072
6499
|
void this.publicWebLink?.close("dashboard revoke");
|
|
6500
|
+
this.forgetPublicLinkOwner();
|
|
6073
6501
|
this.webLoginCodes.revoke();
|
|
6074
6502
|
this.webGuestLoginCodes.revokeAll();
|
|
6075
6503
|
this.guestRevokes++; // and every guest /web still on its way
|
|
@@ -6620,11 +7048,17 @@ export class FleetManager {
|
|
|
6620
7048
|
* landed mid-edit) must not be cleared or disturbed (Prism b).
|
|
6621
7049
|
*/
|
|
6622
7050
|
async appendCleanupWarning(target, watch, warning, cleanupCommand) {
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
7051
|
+
// #2175: one text per outcome, frozen at its first tick. The completion carries elapsed seconds, so a text made
|
|
7052
|
+
// anew each tick would never equal the one already handed over and landed (terminalLanded compares exactly).
|
|
7053
|
+
const key = JSON.stringify([warning, cleanupCommand ?? null]);
|
|
7054
|
+
if (watch.frozen?.key !== key) {
|
|
7055
|
+
watch.frozen = { key, text: formatRestartProgressCompletion("update", {
|
|
7056
|
+
...watch.summary,
|
|
7057
|
+
warningText: warning,
|
|
7058
|
+
warningCommand: cleanupCommand,
|
|
7059
|
+
}, watch.receipt.startedAt) };
|
|
7060
|
+
}
|
|
7061
|
+
const text = watch.frozen.text;
|
|
6628
7062
|
const stopWatching = () => {
|
|
6629
7063
|
this.updateCleanupWatch = null;
|
|
6630
7064
|
if (this.updateProgressTimer)
|
|
@@ -6632,10 +7066,22 @@ export class FleetManager {
|
|
|
6632
7066
|
this.updateProgressTimer = null;
|
|
6633
7067
|
};
|
|
6634
7068
|
if (text !== this.lastUpdateProgressText) {
|
|
6635
|
-
|
|
6636
|
-
|
|
6637
|
-
|
|
6638
|
-
|
|
7069
|
+
// #2175: while the completion is still owed by the background retry, that retry is the message's only
|
|
7070
|
+
// writer — this text (the completion plus the warning) goes to it rather than racing it. Handing it over is not
|
|
7071
|
+
// delivery: the receipt and this watch stay until the retry reports this exact text landed (a later tick).
|
|
7072
|
+
const progress = this.startupProgress;
|
|
7073
|
+
if (progress?.terminalLanded(text)) {
|
|
7074
|
+
// landed through the retry
|
|
7075
|
+
}
|
|
7076
|
+
else if (progress?.replaceTerminalText(text)) {
|
|
7077
|
+
return;
|
|
7078
|
+
}
|
|
7079
|
+
else {
|
|
7080
|
+
const adapter = this.adapters.get(target.adapterId);
|
|
7081
|
+
if (!adapter)
|
|
7082
|
+
return; // retry on a later tick
|
|
7083
|
+
await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
|
|
7084
|
+
}
|
|
6639
7085
|
this.lastUpdateProgressText = text;
|
|
6640
7086
|
}
|
|
6641
7087
|
const verdict = cleanupWatchVerdict(readUpdateProgress(this.dataDir), watch.receipt);
|
|
@@ -6653,7 +7099,7 @@ export class FleetManager {
|
|
|
6653
7099
|
if (this.updateProgressTimer)
|
|
6654
7100
|
clearInterval(this.updateProgressTimer);
|
|
6655
7101
|
const tick = async () => {
|
|
6656
|
-
if (this.updateProgressEditRunning)
|
|
7102
|
+
if (this.updateProgressEditRunning || this.shuttingDown)
|
|
6657
7103
|
return;
|
|
6658
7104
|
const marker = readUpdateProgress(this.dataDir);
|
|
6659
7105
|
if (!marker) {
|
|
@@ -6704,6 +7150,11 @@ export class FleetManager {
|
|
|
6704
7150
|
return;
|
|
6705
7151
|
}
|
|
6706
7152
|
}
|
|
7153
|
+
// #2476: from "starting" onward RestartProgress in the NEW process owns this
|
|
7154
|
+
// message (resume → N/M → ✅ finish). A late CLI-stage edit of "installed" is
|
|
7155
|
+
// what made "✅ 安裝完成" land after "啟動中 N/M". Failure still edits here.
|
|
7156
|
+
if (marker.progress.stage === "starting")
|
|
7157
|
+
return;
|
|
6707
7158
|
const target = marker.progress.target;
|
|
6708
7159
|
const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
|
|
6709
7160
|
if (!adapter)
|
|
@@ -6739,11 +7190,46 @@ export class FleetManager {
|
|
|
6739
7190
|
}
|
|
6740
7191
|
/** Start all instances from fleet config */
|
|
6741
7192
|
async startAll(configPath) {
|
|
7193
|
+
this.startupMemoryDeadline?.stop();
|
|
7194
|
+
const deadline = new StartupMemoryDeadline({
|
|
7195
|
+
current: () => this.startupMemoryDeadline === deadline && !this.shuttingDown,
|
|
7196
|
+
waiting: () => this.spawnGate.isStartupMemoryDeferred(),
|
|
7197
|
+
notify: sdNotify,
|
|
7198
|
+
log: message => this.logger.info(message),
|
|
7199
|
+
});
|
|
7200
|
+
this.startupMemoryDeadline = deadline;
|
|
7201
|
+
// A held first General must not postpone event-loop liveness monitoring.
|
|
7202
|
+
// No independent heartbeat: a genuinely frozen main loop still stops pinging.
|
|
7203
|
+
setSdNotifyWarn((fields, message) => this.logger.warn(fields, message));
|
|
7204
|
+
if (this.watchdogTimer)
|
|
7205
|
+
clearInterval(this.watchdogTimer);
|
|
7206
|
+
this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
|
|
7207
|
+
this.eventLoopWatch?.stop();
|
|
7208
|
+
this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
|
|
7209
|
+
try {
|
|
7210
|
+
await this.startAllWithinDeadline(configPath, deadline);
|
|
7211
|
+
}
|
|
7212
|
+
finally {
|
|
7213
|
+
deadline.stop();
|
|
7214
|
+
if (this.startupMemoryDeadline === deadline) {
|
|
7215
|
+
this.startupMemoryDeadline = null;
|
|
7216
|
+
if (!this.startupComplete) {
|
|
7217
|
+
if (this.watchdogTimer)
|
|
7218
|
+
clearInterval(this.watchdogTimer);
|
|
7219
|
+
this.watchdogTimer = null;
|
|
7220
|
+
this.eventLoopWatch?.stop();
|
|
7221
|
+
this.eventLoopWatch = null;
|
|
7222
|
+
}
|
|
7223
|
+
}
|
|
7224
|
+
}
|
|
7225
|
+
}
|
|
7226
|
+
async startAllWithinDeadline(configPath, deadline) {
|
|
6742
7227
|
this.loginWindow.reopen(); // a stopAll → startAll restart must accept login windows again
|
|
6743
7228
|
this.loginController?.reopen();
|
|
6744
7229
|
const startupStartedAt = Date.now();
|
|
6745
7230
|
FleetManager.signalTarget = this;
|
|
6746
7231
|
this.startupComplete = false;
|
|
7232
|
+
this.startupDashboard = null;
|
|
6747
7233
|
// Cleared here, not at the end of doStopAll: a stop has an async tail, and
|
|
6748
7234
|
// anything arriving during it is still part of the stop.
|
|
6749
7235
|
this.shuttingDown = false;
|
|
@@ -6757,6 +7243,12 @@ export class FleetManager {
|
|
|
6757
7243
|
envLease.release();
|
|
6758
7244
|
}
|
|
6759
7245
|
this.ensureDeliveryOutbox();
|
|
7246
|
+
// #1956: the spawn helper the fleet's tmux/stty commands are started by (its fork is cheap; the fleet's is not).
|
|
7247
|
+
// Started here and by the fleet's own timer — never by a tool handler — and stopped with the fleet.
|
|
7248
|
+
tmuxExecHelper.reopen();
|
|
7249
|
+
tmuxExecHelper.start();
|
|
7250
|
+
this.tmuxExecHelperTimer ??= setInterval(() => tmuxExecHelper.maintain(), 5_000);
|
|
7251
|
+
this.tmuxExecHelperTimer.unref?.();
|
|
6760
7252
|
this.rotateFleetLogs();
|
|
6761
7253
|
const fleet = this.loadConfig(configPath);
|
|
6762
7254
|
this.slimFleetConfigAtStartup();
|
|
@@ -6853,6 +7345,7 @@ export class FleetManager {
|
|
|
6853
7345
|
// #1565: the web chat as it was before this process — the configured instances' only (fleet.yaml and ClassicBot
|
|
6854
7346
|
// rooms), before the dashboard serves anything; from now on every change is written back (off the loop).
|
|
6855
7347
|
this.restoreWebChat(fleet);
|
|
7348
|
+
this.startSearchIndex(fleet);
|
|
6856
7349
|
// Poll classicBot.yaml for external changes every 30s
|
|
6857
7350
|
this.classicReloadTimer = setInterval(() => {
|
|
6858
7351
|
// Attributes the synchronous part, up to the reload's first await (#1235).
|
|
@@ -6902,6 +7395,17 @@ export class FleetManager {
|
|
|
6902
7395
|
return DailySummary.generateText(this.eventLog, instances, costMap, this.costGuard?.getFleetTotalCents() ?? 0);
|
|
6903
7396
|
});
|
|
6904
7397
|
this.dailySummary.start();
|
|
7398
|
+
// #2246 step 3: the task board's daily summary — its own time (defaults.task_board.summary_at, read at every look),
|
|
7399
|
+
// in the fleet's zone; a summary missed while the fleet was down goes out when it is back.
|
|
7400
|
+
this.taskBoardSummary = new TaskBoardSummaryClock({
|
|
7401
|
+
at: () => summaryTime(this.fleetConfig?.defaults?.task_board?.summary_at) ?? null,
|
|
7402
|
+
zone: () => this.usageZone(),
|
|
7403
|
+
now: () => new Date(),
|
|
7404
|
+
mark: summaryMarkFile(join(this.dataDir, "task-board-summary.json")),
|
|
7405
|
+
send: (since, until) => this.postTaskBoardSummary(since, until),
|
|
7406
|
+
failed: (err) => this.logger.warn({ err }, "The task board's daily summary failed; the next minute tries again"),
|
|
7407
|
+
});
|
|
7408
|
+
this.taskBoardSummary.start();
|
|
6905
7409
|
// Rotate classic channel chat logs daily (piggyback on daily summary timer)
|
|
6906
7410
|
this.classicChannels?.rotateLogs();
|
|
6907
7411
|
this.rotateInboxes();
|
|
@@ -6977,7 +7481,7 @@ export class FleetManager {
|
|
|
6977
7481
|
continue;
|
|
6978
7482
|
this.logger.warn({ adapter: need.adapterId, name }, "No general instance for adapter — auto-creating");
|
|
6979
7483
|
const generalDir = join(getAgendHome(), name);
|
|
6980
|
-
mkdirSync(generalDir, { recursive: true });
|
|
7484
|
+
mkdirSync(generalDir, { recursive: true, mode: 0o700 });
|
|
6981
7485
|
const backendName = fleet.defaults.backend ?? "claude-code";
|
|
6982
7486
|
this.ensureGeneralInstructions(generalDir, backendName, name);
|
|
6983
7487
|
fleet.instances[name] = {
|
|
@@ -6997,7 +7501,9 @@ export class FleetManager {
|
|
|
6997
7501
|
};
|
|
6998
7502
|
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);
|
|
6999
7503
|
this.scheduler.init();
|
|
7504
|
+
this.scheduler.db.onTaskChange = (task) => this.searchIndex?.record(taskDoc(task));
|
|
7000
7505
|
this.logger.info("Scheduler initialized");
|
|
7506
|
+
this.attachTaskBoard(this.scheduler.db);
|
|
7001
7507
|
// Tips share the daily-report clock but remain an independent internal
|
|
7002
7508
|
// job: disabling daily_summary must not disable tips (and vice versa).
|
|
7003
7509
|
// The callback checks defaults.tips at fire time, so /tips on|off is hot.
|
|
@@ -7025,6 +7531,14 @@ export class FleetManager {
|
|
|
7025
7531
|
const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
|
|
7026
7532
|
const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
|
|
7027
7533
|
const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingProgressOperation === "full-restart" ? "reload" : pendingUpdateProgress ? "update" : "restart" });
|
|
7534
|
+
this.startupProgress = startupProgress;
|
|
7535
|
+
// Auth, restored chat, Classic roster and the task DB are ready. Listening is not service readiness:
|
|
7536
|
+
// the dashboard observes startup, while /health and READY retain their existing completion boundary.
|
|
7537
|
+
this.startupDashboard = new FleetStartup(this.configuredStartupInstanceNames(fleet, topicMode)
|
|
7538
|
+
.filter(name => !this.lifecycle.isPaused(name)));
|
|
7539
|
+
await this.startHealthServer(fleet.health_port ?? 19280);
|
|
7540
|
+
if (this.shuttingDown)
|
|
7541
|
+
return;
|
|
7028
7542
|
if (generals.length > 0) {
|
|
7029
7543
|
for (const [name, cfg] of generals) {
|
|
7030
7544
|
try {
|
|
@@ -7089,22 +7603,6 @@ export class FleetManager {
|
|
|
7089
7603
|
return startupProgress.start(this.restartProgressTarget());
|
|
7090
7604
|
});
|
|
7091
7605
|
}
|
|
7092
|
-
// The systemd watchdog answers exactly one question: is this process still
|
|
7093
|
-
// turning its event loop? Pinging from a timer proves that, and after the
|
|
7094
|
-
// blocking child-process calls were made async it is a meaningful signal —
|
|
7095
|
-
// a deadlocked or frozen fleet stops pinging and systemd restarts it.
|
|
7096
|
-
//
|
|
7097
|
-
// It deliberately does NOT gate on fleet health. "No adapter connected" or
|
|
7098
|
-
// "an instance crashed" must not kill the process: the fleet would be restarted
|
|
7099
|
-
// into the same broken state, and a user who has legitimately stopped every
|
|
7100
|
-
// instance would get a restart loop. Those conditions surface through /health
|
|
7101
|
-
// (which now returns 503) and through the General-topic notifications instead.
|
|
7102
|
-
setSdNotifyWarn((fields, message) => this.logger.warn(fields, message));
|
|
7103
|
-
this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
|
|
7104
|
-
// #1231: a stalled loop is what turns a slash command into "did not respond";
|
|
7105
|
-
// put each long stall in the log instead of leaving it to be guessed at.
|
|
7106
|
-
this.eventLoopWatch?.stop();
|
|
7107
|
-
this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
|
|
7108
7606
|
// EventLog.prune() existed but was never called, so `events` and `activity`
|
|
7109
7607
|
// grew without bound for the life of the install. Prune once at startup and
|
|
7110
7608
|
// daily after that; the timer is unref'd so it never holds the loop open.
|
|
@@ -7223,6 +7721,10 @@ export class FleetManager {
|
|
|
7223
7721
|
// operation so an older stop never reports. No pending operation means
|
|
7224
7722
|
// a plain boot — nothing was stopped by an operation, report nothing.
|
|
7225
7723
|
const backgroundWorkEndedNames = this.collectBackgroundWorkEndedNames(configuredNames, pendingUpdateProgress?.startedAt ?? null);
|
|
7724
|
+
// #2264: an update whose fleet comes back on another version did not land — say so in its final message (the
|
|
7725
|
+
// version it aimed for, the one running, and the one repair), never just "updated".
|
|
7726
|
+
const aimedAt = pendingProgressOperation === "update" ? pendingUpdateProgress?.progress.version : undefined;
|
|
7727
|
+
const notLanded = aimedAt && agendVersion !== "unknown" && aimedAt !== agendVersion ? t("update.not_landed", aimedAt, agendVersion) : null;
|
|
7226
7728
|
const completionSummary = {
|
|
7227
7729
|
running: started,
|
|
7228
7730
|
total,
|
|
@@ -7232,7 +7734,7 @@ export class FleetManager {
|
|
|
7232
7734
|
backgroundWorkEndedNames,
|
|
7233
7735
|
...(cleanupOutcome && "render" in cleanupOutcome
|
|
7234
7736
|
? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
|
|
7235
|
-
: {}),
|
|
7737
|
+
: notLanded ? { warningText: notLanded } : {}),
|
|
7236
7738
|
};
|
|
7237
7739
|
const progressCompleted = await startupProgress.finish(completionSummary);
|
|
7238
7740
|
// Armed only after the completion posted: an append racing ahead of it would be overwritten by it.
|
|
@@ -7276,9 +7778,9 @@ export class FleetManager {
|
|
|
7276
7778
|
}
|
|
7277
7779
|
// After "fleet ready", so the notice is not the first thing people see of a restart.
|
|
7278
7780
|
void this.announceWebChatOnce(agendVersion);
|
|
7781
|
+
// #named-link-ttl: a named link with auto_open comes back now — after the web listener and the chat connections.
|
|
7782
|
+
this.startPublicLinkAutoOpen();
|
|
7279
7783
|
}
|
|
7280
|
-
// Health HTTP endpoint
|
|
7281
|
-
this.startHealthServer(fleet.health_port ?? 19280);
|
|
7282
7784
|
// #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
|
|
7283
7785
|
// #1823: and polls: a deadline that passed meanwhile closes now (results once); an open one's countdown is redrawn.
|
|
7284
7786
|
void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
|
|
@@ -7289,17 +7791,32 @@ export class FleetManager {
|
|
|
7289
7791
|
}, 60 * 60 * 1000);
|
|
7290
7792
|
const onRestart = () => this.onGracefulRestartSignal(() => process.once("SIGUSR2", onRestart));
|
|
7291
7793
|
process.once("SIGUSR2", onRestart);
|
|
7292
|
-
// SIGUSR1: full process reload (graceful stop → exit
|
|
7794
|
+
// SIGUSR1: full process reload (graceful stop → exit). Nothing in this AgEnD sends it any more (#2356); an older
|
|
7795
|
+
// CLI or a hand-sent signal still may, and the exit code then lets the owning manager start the next fleet.
|
|
7293
7796
|
process.once("SIGUSR1", () => this.onFullRestartSignal());
|
|
7294
7797
|
// A SIGHUP may arrive after the PID/general is available but before the
|
|
7295
7798
|
// rest of startup finishes. Replay one coalesced reload only after all
|
|
7296
7799
|
// startup-owned lifecycle work and signal handlers are in place.
|
|
7800
|
+
if (this.shuttingDown || this.startupMemoryDeadline !== deadline)
|
|
7801
|
+
return;
|
|
7297
7802
|
this.finishStartup(bootUpdateMarker);
|
|
7803
|
+
this.startupDashboard = null;
|
|
7804
|
+
this.publishStartupStatus();
|
|
7298
7805
|
// Tell systemd we are ready only now. This used to fire right after the
|
|
7299
7806
|
// generals started — before adapters, classic instances, topic creation and the
|
|
7300
7807
|
// health server — so `systemctl start` returned success while the fleet was
|
|
7301
7808
|
// still deaf: no path existed for a user message to arrive.
|
|
7302
7809
|
sdNotify("READY=1");
|
|
7810
|
+
// #2481: this version runs — the CLI's start-failure banner stops, and this is the version to go back to later.
|
|
7811
|
+
try {
|
|
7812
|
+
const { ownPackage, recordStarted } = await import("./start-failure.js");
|
|
7813
|
+
const version = ownPackage().version;
|
|
7814
|
+
if (version)
|
|
7815
|
+
recordStarted(this.dataDir, version, process.pid, Date.now());
|
|
7816
|
+
}
|
|
7817
|
+
catch (err) {
|
|
7818
|
+
this.logger.debug({ err }, "fleet-started record not written");
|
|
7819
|
+
}
|
|
7303
7820
|
const health = this.getFleetHealth();
|
|
7304
7821
|
if (health.status !== "ok") {
|
|
7305
7822
|
this.logger.warn({ health }, "Fleet started with problems — see /health");
|
|
@@ -7452,48 +7969,109 @@ export class FleetManager {
|
|
|
7452
7969
|
const err = results[i].reason;
|
|
7453
7970
|
this.logger.error({ adapterId, err: err?.message ?? err }, "Adapter startup failed — scheduling background retry");
|
|
7454
7971
|
this.adapterState.set(adapterId, { status: "retrying", retryCount: 0, lastError: err?.message ?? String(err) });
|
|
7455
|
-
this.scheduleAdapterRetry(adapterId
|
|
7972
|
+
this.scheduleAdapterRetry(adapterId);
|
|
7456
7973
|
// Notify admin via whichever adapter is already up
|
|
7457
7974
|
this.notifyAdapterFailure(adapterId, err?.message ?? String(err));
|
|
7458
7975
|
}
|
|
7459
7976
|
}
|
|
7460
7977
|
}
|
|
7461
|
-
/**
|
|
7462
|
-
scheduleAdapterRetry(adapterId
|
|
7463
|
-
const MAX_RETRIES = 10;
|
|
7978
|
+
/** Keep trying while the fleet runs, with a five-minute cap even after a long outage. */
|
|
7979
|
+
scheduleAdapterRetry(adapterId) {
|
|
7464
7980
|
const INITIAL_DELAY_MS = 5_000;
|
|
7465
7981
|
const MAX_DELAY_MS = 5 * 60_000;
|
|
7466
7982
|
const state = this.adapterState.get(adapterId);
|
|
7467
|
-
if (!state || state.
|
|
7468
|
-
if (state) {
|
|
7469
|
-
state.status = "failed";
|
|
7470
|
-
this.logger.error({ adapterId, retries: state.retryCount }, "Adapter retry exhausted — giving up");
|
|
7471
|
-
this.notifyAdapterFailure(adapterId, `Retry exhausted after ${state.retryCount} attempts. Check token/network and restart fleet.`);
|
|
7472
|
-
}
|
|
7983
|
+
if (this.shuttingDown || !state || state.retryTimer || this.adapterRetryInFlight.has(adapterId))
|
|
7473
7984
|
return;
|
|
7474
|
-
|
|
7475
|
-
const delay = Math.min(INITIAL_DELAY_MS * Math.pow(2, state.retryCount), MAX_DELAY_MS);
|
|
7985
|
+
const delay = Math.min(INITIAL_DELAY_MS * Math.pow(2, Math.min(state.retryCount, 6)), MAX_DELAY_MS);
|
|
7476
7986
|
this.logger.info({ adapterId, attempt: state.retryCount + 1, delay_ms: delay }, "Scheduling adapter retry");
|
|
7477
|
-
|
|
7987
|
+
const timer = setTimeout(() => {
|
|
7988
|
+
const current = this.adapterState.get(adapterId);
|
|
7989
|
+
if (this.shuttingDown || current?.retryTimer !== timer)
|
|
7990
|
+
return;
|
|
7991
|
+
current.retryTimer = undefined;
|
|
7992
|
+
void this.attemptAdapterRetry(adapterId);
|
|
7993
|
+
}, delay);
|
|
7994
|
+
state.retryTimer = timer;
|
|
7995
|
+
timer.unref?.();
|
|
7996
|
+
}
|
|
7997
|
+
/** One login per connection, shared by the background timer and the operator's Retry now. */
|
|
7998
|
+
attemptAdapterRetry(adapterId) {
|
|
7999
|
+
const pending = this.adapterRetryInFlight.get(adapterId);
|
|
8000
|
+
if (pending)
|
|
8001
|
+
return pending;
|
|
8002
|
+
if (this.shuttingDown || !this.secureConnectionChannel(adapterId))
|
|
8003
|
+
return Promise.resolve();
|
|
8004
|
+
const attempt = Promise.resolve().then(async () => {
|
|
8005
|
+
const channel = this.secureConnectionChannel(adapterId);
|
|
8006
|
+
if (this.shuttingDown || !channel)
|
|
8007
|
+
return;
|
|
8008
|
+
const generation = this.settingsGeneration;
|
|
8009
|
+
const binding = JSON.stringify([channel, this.getPrimaryAdapterId()]);
|
|
8010
|
+
const token = process.env[channel.bot_token_env];
|
|
8011
|
+
const operation = { owner: Symbol("adapter-retry"), assert: () => {
|
|
8012
|
+
if (this.shuttingDown || generation !== this.settingsGeneration
|
|
8013
|
+
|| JSON.stringify([this.secureConnectionChannel(adapterId), this.getPrimaryAdapterId()]) !== binding
|
|
8014
|
+
|| process.env[channel.bot_token_env] !== token)
|
|
8015
|
+
throw new Error("adapter retry superseded");
|
|
8016
|
+
} };
|
|
8017
|
+
const state = this.adapterState.get(adapterId) ?? { status: "retrying", retryCount: 0 };
|
|
8018
|
+
state.status = "retrying";
|
|
7478
8019
|
state.retryCount++;
|
|
8020
|
+
this.adapterState.set(adapterId, state);
|
|
7479
8021
|
try {
|
|
7480
|
-
if (
|
|
7481
|
-
await this.startSingleAdapter(
|
|
7482
|
-
|
|
7483
|
-
|
|
7484
|
-
|
|
7485
|
-
|
|
7486
|
-
|
|
7487
|
-
|
|
8022
|
+
if (this.getPrimaryAdapterId() === adapterId)
|
|
8023
|
+
await this.startSingleAdapter(this.fleetConfig, channel, undefined, operation);
|
|
8024
|
+
else
|
|
8025
|
+
await this.startAdditionalAdapter(channel, true, undefined, operation);
|
|
8026
|
+
if (this.shuttingDown || generation !== this.settingsGeneration)
|
|
8027
|
+
return;
|
|
8028
|
+
if (!this.adapters.has(adapterId))
|
|
8029
|
+
throw new Error("Adapter token is not configured");
|
|
8030
|
+
// Health events can replace the state object during login. Update its current owner.
|
|
8031
|
+
const current = this.adapterState.get(adapterId);
|
|
8032
|
+
current.status = "connected";
|
|
8033
|
+
current.lastError = undefined;
|
|
7488
8034
|
this.logger.info({ adapterId, attempts: state.retryCount }, "Adapter reconnected on retry");
|
|
7489
8035
|
this.notifyAdapterRecovery(adapterId, state.retryCount);
|
|
7490
8036
|
}
|
|
7491
8037
|
catch (err) {
|
|
7492
|
-
|
|
7493
|
-
|
|
7494
|
-
this.
|
|
8038
|
+
if (this.shuttingDown || generation !== this.settingsGeneration)
|
|
8039
|
+
return;
|
|
8040
|
+
const current = this.adapterState.get(adapterId);
|
|
8041
|
+
current.status = "retrying";
|
|
8042
|
+
current.lastError = err?.message ?? String(err);
|
|
8043
|
+
this.logger.warn({ adapterId, attempt: state.retryCount, err: current.lastError }, "Adapter retry failed");
|
|
7495
8044
|
}
|
|
7496
|
-
}
|
|
8045
|
+
}).finally(() => {
|
|
8046
|
+
if (this.adapterRetryInFlight.get(adapterId) !== attempt)
|
|
8047
|
+
return;
|
|
8048
|
+
this.adapterRetryInFlight.delete(adapterId);
|
|
8049
|
+
const channel = this.secureConnectionChannel(adapterId);
|
|
8050
|
+
if (!this.shuttingDown && channel && this.adapterState.get(adapterId)?.status !== "connected") {
|
|
8051
|
+
this.scheduleAdapterRetry(adapterId);
|
|
8052
|
+
}
|
|
8053
|
+
});
|
|
8054
|
+
this.adapterRetryInFlight.set(adapterId, attempt);
|
|
8055
|
+
return attempt;
|
|
8056
|
+
}
|
|
8057
|
+
retryAdapterNow(adapterId) {
|
|
8058
|
+
if (this.shuttingDown)
|
|
8059
|
+
throw new Error("The fleet is stopping");
|
|
8060
|
+
if (!this.secureConnectionChannel(adapterId))
|
|
8061
|
+
throw new Error("Connection not found");
|
|
8062
|
+
if (this.adapterRetryInFlight.has(adapterId) || this.adapterRestarting.has(adapterId))
|
|
8063
|
+
return { queued: true, already_running: true };
|
|
8064
|
+
const state = this.adapterState.get(adapterId);
|
|
8065
|
+
if (state?.retryTimer) {
|
|
8066
|
+
clearTimeout(state.retryTimer);
|
|
8067
|
+
state.retryTimer = undefined;
|
|
8068
|
+
}
|
|
8069
|
+
const adapter = this.adapters.get(adapterId);
|
|
8070
|
+
if (adapter && this.connectionStatus(adapterId, state, this.worlds.get(adapterId)).status === "connected")
|
|
8071
|
+
return { queued: false };
|
|
8072
|
+
const run = adapter ? this.restartAdapter(adapter, adapterId) : this.attemptAdapterRetry(adapterId);
|
|
8073
|
+
void run.catch(err => this.logger.warn({ adapterId, err }, "Requested adapter retry failed"));
|
|
8074
|
+
return { queued: true };
|
|
7497
8075
|
}
|
|
7498
8076
|
/** Notify admin about adapter failure (uses any available adapter). */
|
|
7499
8077
|
notifyAdapterFailure(adapterId, error) {
|
|
@@ -7548,6 +8126,7 @@ export class FleetManager {
|
|
|
7548
8126
|
: snapshot.status === "stopped" ? "failed"
|
|
7549
8127
|
: "retrying";
|
|
7550
8128
|
this.adapterState.set(adapterId, {
|
|
8129
|
+
...previous,
|
|
7551
8130
|
status,
|
|
7552
8131
|
retryCount: previous?.retryCount ?? 0,
|
|
7553
8132
|
lastError: status === "connected" ? undefined : snapshot.lastReconnectReason ?? previous?.lastError,
|
|
@@ -7656,12 +8235,12 @@ export class FleetManager {
|
|
|
7656
8235
|
return;
|
|
7657
8236
|
}
|
|
7658
8237
|
const accessDir = join(this.dataDir, "access");
|
|
7659
|
-
mkdirSync(accessDir, { recursive: true });
|
|
8238
|
+
mkdirSync(accessDir, { recursive: true, mode: 0o700 });
|
|
7660
8239
|
const accessStatePath = join(accessDir, "access.json");
|
|
7661
8240
|
const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
|
|
7662
8241
|
this.warnIfAccessModeOverridden(accessManager, accessStatePath);
|
|
7663
8242
|
const inboxDir = join(this.dataDir, "inbox");
|
|
7664
|
-
mkdirSync(inboxDir, { recursive: true });
|
|
8243
|
+
mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
|
|
7665
8244
|
const adapterId = channelConfig.id ?? channelConfig.type;
|
|
7666
8245
|
const adapter = await createAdapter(channelConfig, {
|
|
7667
8246
|
id: adapterId,
|
|
@@ -7807,12 +8386,12 @@ export class FleetManager {
|
|
|
7807
8386
|
return;
|
|
7808
8387
|
}
|
|
7809
8388
|
const accessDir = join(this.dataDir, "access");
|
|
7810
|
-
mkdirSync(accessDir, { recursive: true });
|
|
8389
|
+
mkdirSync(accessDir, { recursive: true, mode: 0o700 });
|
|
7811
8390
|
const accessStatePath = join(accessDir, `access-${adapterId}.json`);
|
|
7812
8391
|
const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
|
|
7813
8392
|
this.warnIfAccessModeOverridden(accessManager, accessStatePath);
|
|
7814
8393
|
const inboxDir = join(this.dataDir, "inbox");
|
|
7815
|
-
mkdirSync(inboxDir, { recursive: true });
|
|
8394
|
+
mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
|
|
7816
8395
|
const adapter = await createAdapter(channelConfig, {
|
|
7817
8396
|
id: adapterId,
|
|
7818
8397
|
botToken,
|
|
@@ -8118,7 +8697,7 @@ export class FleetManager {
|
|
|
8118
8697
|
// means we cannot verify the pane, which is treated as dead (the same
|
|
8119
8698
|
// conclusion the old code reached only by throwing).
|
|
8120
8699
|
try {
|
|
8121
|
-
const { execFile } = await import("
|
|
8700
|
+
const { execFile } = await import("./child-process.js");
|
|
8122
8701
|
const { promisify } = await import("node:util");
|
|
8123
8702
|
const { getTmuxSocketName } = await import("./paths.js");
|
|
8124
8703
|
// Honour socket isolation: without -L this queried the user's default
|
|
@@ -8668,6 +9247,22 @@ export class FleetManager {
|
|
|
8668
9247
|
this.logger.debug({ adapterId: msg.adapterId, threadId: threadId ?? null, messageId: msg.messageId, reason: drop }, "Inbound message dropped before the dedup claim — key not consumed");
|
|
8669
9248
|
return;
|
|
8670
9249
|
}
|
|
9250
|
+
// #2246 step 3 (#2341 review r2): a reply to a Needs you Send back / Answer prompt is the card's reason or answer, not
|
|
9251
|
+
// input for an instance — judged by the PROMPT's world (its admins, its instance, its room as it is now; the hub's
|
|
9252
|
+
// handleReply), before the room's generic access below: in a shared group the room may be another bot's, whose
|
|
9253
|
+
// policy has no say over that world's prompt. Only that exact reply, by the admin who clicked, while the prompt
|
|
9254
|
+
// stands. Taken once: another bot's copy of a taken reply is dropped here. All else flows on.
|
|
9255
|
+
if (!msg.isBotMessage && msg.replyTo && this.needsYou) {
|
|
9256
|
+
if (this.needsYou.wasTakenReply(msg.chatId, msg.messageId)) {
|
|
9257
|
+
this.logger.debug({ adapterId: msg.adapterId, messageId: msg.messageId }, "Another bot's copy of a Needs you answer already taken — dropped");
|
|
9258
|
+
return;
|
|
9259
|
+
}
|
|
9260
|
+
if (this.needsYou.handleReply({
|
|
9261
|
+
adapterId: msg.adapterId, chatId: msg.chatId, ...(threadId !== undefined ? { threadId } : {}), replyTo: msg.replyTo,
|
|
9262
|
+
messageId: msg.messageId, userId: msg.userId, ...(msg.username ? { username: msg.username } : {}), text: msg.text ?? "",
|
|
9263
|
+
}))
|
|
9264
|
+
return;
|
|
9265
|
+
}
|
|
8671
9266
|
// Access control — classic channels are open to all, others require an allowed
|
|
8672
9267
|
// user. This runs before the dedup claim, and is judged by the adapter that
|
|
8673
9268
|
// owns the topic rather than the one that happened to receive the message:
|
|
@@ -8778,6 +9373,10 @@ export class FleetManager {
|
|
|
8778
9373
|
// #1920 step 4b-3: /web typed in a ClassicBot room (a group: `/web@ThisBot`) by someone who is not a fleet admin,
|
|
8779
9374
|
// while guest sign-in is on: their guest code, privately — as anywhere else. An admin's goes on as before.
|
|
8780
9375
|
if (/^\/(?:web|dashboard)(?:\s|$)/i.test(text) && msgAdapter && this.isGuestWebAsk(msg.adapterId, msg.userId)) {
|
|
9376
|
+
// #2359: this door must not skip the deprecation notice TopicCommands shows on /dashboard.
|
|
9377
|
+
if (/^\/dashboard(?:\s|$)/i.test(text)) {
|
|
9378
|
+
await msgAdapter.sendText(chatId, t("dashboard.deprecated")).catch(() => { });
|
|
9379
|
+
}
|
|
8781
9380
|
await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username,
|
|
8782
9381
|
arg: text.replace(/^\/(?:web|dashboard)/i, "").trim().toLowerCase(), replyHere: body => msgAdapter.sendText(chatId, body) });
|
|
8783
9382
|
return;
|
|
@@ -9224,9 +9823,14 @@ export class FleetManager {
|
|
|
9224
9823
|
* history (#1565). #1920 §5.2: said in the instance's topic, General or room, so it is that channel's, by that account.
|
|
9225
9824
|
*/
|
|
9226
9825
|
emitPlatformInbound(instance, msg, text) {
|
|
9826
|
+
// #2281: where it is on the platform — a web reply to it is then a native reply there. The platform thread it came
|
|
9827
|
+
// from (#1085: a ClassicBot's `threadId` may be its registry key).
|
|
9828
|
+
const thread = msg.transportThreadId ?? msg.threadId;
|
|
9829
|
+
const posts = msg.adapterId && msg.chatId && msg.messageId
|
|
9830
|
+
? [{ adapterId: msg.adapterId, chatId: msg.chatId, ...(thread && thread !== msg.chatId ? { threadId: thread } : {}), messageId: msg.messageId }] : undefined;
|
|
9227
9831
|
this.emitSseEvent("message", {
|
|
9228
9832
|
instance, sender: msg.username, role: "user", text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
|
|
9229
|
-
channel: true, from: webChatFrom(msg.adapterId, msg.userId),
|
|
9833
|
+
channel: true, from: webChatFrom(msg.adapterId, msg.userId), ...(posts ? { posts } : {}),
|
|
9230
9834
|
});
|
|
9231
9835
|
}
|
|
9232
9836
|
/** Handle outbound tool calls from a daemon instance */
|
|
@@ -9328,12 +9932,21 @@ export class FleetManager {
|
|
|
9328
9932
|
}
|
|
9329
9933
|
}
|
|
9330
9934
|
checkToolPermission(sink, instanceName, tool) {
|
|
9935
|
+
if (isCanvasWorkerTool(tool) && !this.canvasWorkerEnabled())
|
|
9936
|
+
return { allowed: false, message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE };
|
|
9331
9937
|
const profile = resolveToolSet(this.fleetConfig?.instances[instanceName], instanceName);
|
|
9332
9938
|
const allowed = mayUseTool(profile, tool);
|
|
9333
9939
|
if (!allowed) {
|
|
9334
9940
|
this.logger.warn({ sink, instance: instanceName, profile, tool, enforced: true }, "tool-permissions: refused");
|
|
9335
9941
|
return { allowed: false, message: toolRefusedMessage(profile, tool) };
|
|
9336
9942
|
}
|
|
9943
|
+
// The person's dashboard switches (dashboard-permissions.ts), on top of the profile, as configured now.
|
|
9944
|
+
// Its own switches, else the defaults' — a ClassicBot identity has no instances entry and runs on the defaults (#2391 review).
|
|
9945
|
+
const switchedOff = dashboardRefusalIn(this.fleetConfig, instanceName, tool);
|
|
9946
|
+
if (switchedOff) {
|
|
9947
|
+
this.logger.warn({ sink, instance: instanceName, profile, tool, enforced: true, dashboard: true }, "tool-permissions: refused");
|
|
9948
|
+
return { allowed: false, message: switchedOff };
|
|
9949
|
+
}
|
|
9337
9950
|
this.logger.debug({ sink, instance: instanceName, profile, tool, enforced: true }, "tool-permissions: allowed");
|
|
9338
9951
|
return { allowed: true, message: "" };
|
|
9339
9952
|
}
|
|
@@ -9341,6 +9954,9 @@ export class FleetManager {
|
|
|
9341
9954
|
this.touchActivity(instanceName);
|
|
9342
9955
|
this.setTopicIcon(instanceName, "green");
|
|
9343
9956
|
const tool = msg.tool;
|
|
9957
|
+
// #1774: who sends this — taken before anything here awaits (sticker checks, the platform send): a reply that waits
|
|
9958
|
+
// must never be read as a same-name replacement's (removed and re-registered meanwhile).
|
|
9959
|
+
const sentBy = tool === "reply" ? this.webChatIdentity(instanceName) : undefined;
|
|
9344
9960
|
const args = (msg.args ?? {});
|
|
9345
9961
|
const requestId = msg.requestId;
|
|
9346
9962
|
const fleetRequestId = msg.fleetRequestId;
|
|
@@ -9373,6 +9989,27 @@ export class FleetManager {
|
|
|
9373
9989
|
respond(null, permitted.message);
|
|
9374
9990
|
return;
|
|
9375
9991
|
}
|
|
9992
|
+
// Fleet tools resolve their own targets. A missing chat world must not prevent local
|
|
9993
|
+
// delivery, inspection or receipt lookup; only channel tools need reply routing below.
|
|
9994
|
+
const handler = outboundHandlers.get(tool);
|
|
9995
|
+
if (handler && !usesChannelAddress(tool)) {
|
|
9996
|
+
this.eventLog?.logActivity("tool_call", senderSessionName ?? instanceName, this.summarizeToolCall(tool, args));
|
|
9997
|
+
await handler(this, args, respond, {
|
|
9998
|
+
instanceName, requestId, fleetRequestId, senderSessionName, operationId, sourceDaemonBootId,
|
|
9999
|
+
});
|
|
10000
|
+
return;
|
|
10001
|
+
}
|
|
10002
|
+
// #2305: a reaction to a web chat message is the web chat's own (any fleet, web-only included): never an adapter's.
|
|
10003
|
+
if (tool === "react" && typeof args.message_id === "string" && isWebMessageId(args.message_id)) {
|
|
10004
|
+
// The socket's own instance, never a session name the caller wrote itself (as checkToolPermission: it was not
|
|
10005
|
+
// identified) — a channel tool's call from a daemon carries none anyway (#2310 review).
|
|
10006
|
+
const r = await this.reactToWebMessage(instanceName, args.message_id, args.emoji);
|
|
10007
|
+
if (r.ok)
|
|
10008
|
+
respond("ok");
|
|
10009
|
+
else
|
|
10010
|
+
respond(null, r.error);
|
|
10011
|
+
return;
|
|
10012
|
+
}
|
|
9376
10013
|
// A fleet with no chat platform at all is driven from the web dashboard alone: a reply has nowhere
|
|
9377
10014
|
// else to go, and "retry shortly" would have the agent retry forever. It goes to the web chat.
|
|
9378
10015
|
const webOnlyReply = tool === "reply" && this.worlds.size === 0 && this.isWebOnlyFleet();
|
|
@@ -9499,6 +10136,7 @@ export class FleetManager {
|
|
|
9499
10136
|
args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
|
|
9500
10137
|
}
|
|
9501
10138
|
}
|
|
10139
|
+
const replyIdentity = sentBy; // #1774: taken at the handler's entry
|
|
9502
10140
|
const original = respond;
|
|
9503
10141
|
const respondAndRecord = (result, error) => {
|
|
9504
10142
|
let buttonsView = null;
|
|
@@ -9530,12 +10168,13 @@ export class FleetManager {
|
|
|
9530
10168
|
try {
|
|
9531
10169
|
// #1920 §5.2: shown to the instance's guests only when it landed in the instance's own topic or room.
|
|
9532
10170
|
let inChannel = false;
|
|
10171
|
+
const replyAdapterId = contextAdapterId ?? this.getInstanceAdapterId(instanceName);
|
|
9533
10172
|
try {
|
|
9534
10173
|
inChannel = outAdapter !== WEB_ONLY_REPLY_SINK
|
|
9535
|
-
&& this.landedInOwnChannel(instanceName,
|
|
10174
|
+
&& this.landedInOwnChannel(instanceName, replyAdapterId, result);
|
|
9536
10175
|
}
|
|
9537
10176
|
catch { /* not known: not the channel's (fail closed), and the bookkeeping below still runs */ }
|
|
9538
|
-
void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel);
|
|
10177
|
+
void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel, replyAdapterId ? { adapterId: replyAdapterId, result } : undefined, replyIdentity);
|
|
9539
10178
|
}
|
|
9540
10179
|
catch (err) {
|
|
9541
10180
|
this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
|
|
@@ -9564,19 +10203,14 @@ export class FleetManager {
|
|
|
9564
10203
|
if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
|
|
9565
10204
|
return;
|
|
9566
10205
|
}
|
|
9567
|
-
// Log tool calls for activity visualization
|
|
9568
|
-
const senderLabel = senderSessionName ?? instanceName;
|
|
9569
|
-
this.eventLog?.logActivity("tool_call", senderLabel, this.summarizeToolCall(tool, args));
|
|
9570
|
-
// Dispatch fleet-specific tools via handler map
|
|
9571
|
-
const handler = outboundHandlers.get(tool);
|
|
9572
10206
|
if (handler) {
|
|
10207
|
+
this.eventLog?.logActivity("tool_call", senderSessionName ?? instanceName, this.summarizeToolCall(tool, args));
|
|
9573
10208
|
await handler(this, args, respond, {
|
|
9574
10209
|
instanceName, requestId, fleetRequestId, senderSessionName, operationId, sourceDaemonBootId,
|
|
9575
10210
|
});
|
|
9576
10211
|
}
|
|
9577
|
-
else
|
|
10212
|
+
else
|
|
9578
10213
|
respond(null, `Unknown tool: ${tool}`);
|
|
9579
|
-
}
|
|
9580
10214
|
}
|
|
9581
10215
|
/** Side effects of a routed reply: cancel-button lifecycle, logs, SSE, chat log. */
|
|
9582
10216
|
/** No chat platform is configured at all: the web dashboard is the fleet's only surface. */
|
|
@@ -9617,7 +10251,12 @@ export class FleetManager {
|
|
|
9617
10251
|
return false;
|
|
9618
10252
|
}
|
|
9619
10253
|
}
|
|
9620
|
-
afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false) {
|
|
10254
|
+
afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false, sentAs, identity = this.webChatIdentity(instanceName)) {
|
|
10255
|
+
// #2281: where the reply is, when it landed in the instance's own place — a web reply to it is then a native reply
|
|
10256
|
+
// there. A reply elsewhere (another chat, the web-only sink) records none.
|
|
10257
|
+
const sent = (sentAs?.result && typeof sentAs.result === "object" ? sentAs.result : {});
|
|
10258
|
+
const post = inChannel && sentAs && typeof sent.messageId === "string" && typeof sent.chatId === "string"
|
|
10259
|
+
? { adapterId: sentAs.adapterId, chatId: sent.chatId, ...(typeof sent.threadId === "string" && sent.threadId ? { threadId: sent.threadId } : {}), messageId: sent.messageId } : undefined;
|
|
9621
10260
|
// A reply is NOT proof the turn is over (#410) — but it is not proof of
|
|
9622
10261
|
// more work either. Split the difference: an instance that is clearly
|
|
9623
10262
|
// idle loses the button now; one that looks busy keeps it (re-posted
|
|
@@ -9644,7 +10283,16 @@ export class FleetManager {
|
|
|
9644
10283
|
// copies are done, and an instance's replies wait for the one before them: they reach the chat in order.
|
|
9645
10284
|
const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
|
|
9646
10285
|
const ts = new Date().toISOString();
|
|
9647
|
-
|
|
10286
|
+
// #1774: the instance this reply is for — removed while its files were being copied (a same-name replacement
|
|
10287
|
+
// registered meanwhile included), the reply is no longer its to show: no chat made again, none of the new one's
|
|
10288
|
+
// touched, and the copies made for it go. A replacement's own replies are its own.
|
|
10289
|
+
// Its identity was taken when the reply was sent (identity), never re-read after the platform answered.
|
|
10290
|
+
const current = () => this.webChatIdentityCurrent(instanceName, identity);
|
|
10291
|
+
const show = (attachments, reserved) => {
|
|
10292
|
+
if (!current()) {
|
|
10293
|
+
this.webFiles.dropServed(attachments.map(a => a.id));
|
|
10294
|
+
return;
|
|
10295
|
+
}
|
|
9648
10296
|
// The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
|
|
9649
10297
|
// role that may get HTML preview cards. Never inferred from the sender name or the text.
|
|
9650
10298
|
this.emitSseEvent("message", {
|
|
@@ -9654,18 +10302,25 @@ export class FleetManager {
|
|
|
9654
10302
|
...(attachments.length ? { attachments } : {}),
|
|
9655
10303
|
...(buttons ? { buttons } : {}), // #1266
|
|
9656
10304
|
...(inChannel ? { channel: true } : {}), // #1920 §5.2
|
|
10305
|
+
...(post ? { posts: [post] } : {}), // #2281
|
|
10306
|
+
...(reserved !== undefined ? { reserved } : {}), // #1774
|
|
9657
10307
|
});
|
|
9658
10308
|
};
|
|
9659
10309
|
const before = this.webReplyOrder.get(instanceName);
|
|
9660
|
-
|
|
10310
|
+
// #1774: a removal of this instance still going decides whether a reply sent before it began is shown — it waits.
|
|
10311
|
+
const removal = this.webChatRemovalPending(instanceName, identity);
|
|
10312
|
+
if (!replyFiles.length && !before && !removal) {
|
|
9661
10313
|
show([]);
|
|
9662
10314
|
return Promise.resolve();
|
|
9663
10315
|
}
|
|
10316
|
+
// #1774: its place in the chat is now — a person's message sent while the files are copied comes after it.
|
|
10317
|
+
const reserved = this.webChatHistory.reserve();
|
|
9664
10318
|
const done = (before ?? Promise.resolve())
|
|
9665
|
-
.then(() => this.webReplyAttachments(instanceName, replyFiles))
|
|
10319
|
+
.then(() => this.webReplyAttachments(instanceName, replyFiles, current))
|
|
10320
|
+
.then(async (attachments) => { await this.webChatRemovalPending(instanceName, identity); return attachments; })
|
|
9666
10321
|
.catch((err) => { this.logger.warn({ err, instanceName }, "Reply files could not be prepared for the web chat"); return []; })
|
|
9667
10322
|
.then((attachments) => { try {
|
|
9668
|
-
show(attachments);
|
|
10323
|
+
show(attachments, reserved);
|
|
9669
10324
|
}
|
|
9670
10325
|
catch (err) {
|
|
9671
10326
|
this.logger.warn({ err, instanceName }, "Reply could not be shown in the web chat");
|
|
@@ -9680,11 +10335,13 @@ export class FleetManager {
|
|
|
9680
10335
|
* the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
|
|
9681
10336
|
* one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
|
|
9682
10337
|
*/
|
|
9683
|
-
async webReplyAttachments(instanceName, paths) {
|
|
10338
|
+
async webReplyAttachments(instanceName, paths, current = () => true) {
|
|
9684
10339
|
const home = getAgendHome();
|
|
9685
10340
|
const dir = webFilesDir(home, instanceName);
|
|
9686
10341
|
const out = [];
|
|
9687
10342
|
for (const path of paths) {
|
|
10343
|
+
if (!current())
|
|
10344
|
+
break; // #1774: removed meanwhile — copy no more for it
|
|
9688
10345
|
const id = randomBytes(16).toString("hex");
|
|
9689
10346
|
const got = dir ? await snapshotReplyFile(path, dir, id, { stateDir: home, maxDirBytes: this.webReplyMaxBytes }) : null;
|
|
9690
10347
|
if (got && "evicted" in got && got.evicted.length)
|
|
@@ -9996,13 +10653,14 @@ export class FleetManager {
|
|
|
9996
10653
|
if (!ipc || !fleetRequestId)
|
|
9997
10654
|
return;
|
|
9998
10655
|
const type = String(msg.type);
|
|
9999
|
-
const responseType = type.
|
|
10000
|
-
: type.startsWith("
|
|
10001
|
-
: type
|
|
10002
|
-
: type === "
|
|
10003
|
-
: type === "
|
|
10004
|
-
|
|
10005
|
-
|
|
10656
|
+
const responseType = isCanvasWorkerTool(type.replace(/^fleet_/, "")) ? "fleet_web_theme_response"
|
|
10657
|
+
: type.startsWith("fleet_schedule_") ? "fleet_schedule_response"
|
|
10658
|
+
: type.startsWith("fleet_decision_") ? "fleet_decision_response"
|
|
10659
|
+
: type === "fleet_task" ? "fleet_task_response"
|
|
10660
|
+
: type === "fleet_set_display_name" ? "fleet_display_name_response"
|
|
10661
|
+
: type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis"
|
|
10662
|
+
|| type === "fleet_list_stickers" || type === "fleet_preview_stickers" ? "fleet_persona_emoji_response"
|
|
10663
|
+
: "fleet_description_response";
|
|
10006
10664
|
ipc.send({ type: responseType, fleetRequestId, error: message });
|
|
10007
10665
|
}
|
|
10008
10666
|
dispatchTypedIpc(name, msg) {
|
|
@@ -10163,10 +10821,11 @@ export class FleetManager {
|
|
|
10163
10821
|
}
|
|
10164
10822
|
case "fleet_decision_list":
|
|
10165
10823
|
db.pruneExpiredDecisions();
|
|
10166
|
-
|
|
10824
|
+
// #2152: titles by default; the full text by id, or all of it with full: true.
|
|
10825
|
+
result = decisionListAnswer(db.listDecisions(projectRoot, {
|
|
10167
10826
|
includeArchived: payload.include_archived,
|
|
10168
10827
|
tags: payload.tags,
|
|
10169
|
-
});
|
|
10828
|
+
}), { id: payload.id, full: payload.full });
|
|
10170
10829
|
break;
|
|
10171
10830
|
case "fleet_decision_update": {
|
|
10172
10831
|
const id = payload.id;
|
|
@@ -10359,10 +11018,16 @@ export class FleetManager {
|
|
|
10359
11018
|
created_by: instance,
|
|
10360
11019
|
});
|
|
10361
11020
|
}
|
|
10362
|
-
|
|
10363
|
-
|
|
10364
|
-
|
|
10365
|
-
|
|
11021
|
+
// #2152: titles by default (agend-agent decision-list); one decision's full text by id (decision-get <id>).
|
|
11022
|
+
// The CLI sends include_archived (snake case, as the MCP tool): both spellings are read.
|
|
11023
|
+
case "list":
|
|
11024
|
+
case "get":
|
|
11025
|
+
if (op === "get" && !asStr(args.id)?.trim())
|
|
11026
|
+
return { error: "id is required" };
|
|
11027
|
+
return decisionListAnswer(db.listDecisions(projectRoot, {
|
|
11028
|
+
includeArchived: args.includeArchived === true || args.include_archived === true,
|
|
11029
|
+
tags: asStrArr(args.tags),
|
|
11030
|
+
}), { id: op === "get" ? asStr(args.id) ?? "" : args.id, full: args.full });
|
|
10366
11031
|
case "update": {
|
|
10367
11032
|
const id = asStr(args.id);
|
|
10368
11033
|
if (!id)
|
|
@@ -10387,14 +11052,20 @@ export class FleetManager {
|
|
|
10387
11052
|
return (v === "low" || v === "normal" || v === "high" || v === "urgent") ? v : undefined;
|
|
10388
11053
|
};
|
|
10389
11054
|
const asStatus = (v) => {
|
|
10390
|
-
return (v === "open" || v === "claimed" || v === "done" || v === "blocked" || v === "cancelled") ? v : undefined;
|
|
11055
|
+
return (v === "open" || v === "claimed" || v === "review" || v === "done" || v === "blocked" || v === "cancelled") ? v : undefined;
|
|
10391
11056
|
};
|
|
10392
11057
|
// #1336 P2: use the shared normalizer — trims whitespace AND drops empties,
|
|
10393
11058
|
// so " \t " / [" ","\t"] are treated identically to undefined.
|
|
10394
11059
|
const asStatusFilter = normalizeStatusFilter;
|
|
10395
11060
|
// #1336: small write ack — identifying fields only.
|
|
10396
11061
|
const ack = (t) => ({ id: t.id, status: t.status, updated_at: t.updated_at });
|
|
11062
|
+
// The board's notes for the write (a send-back reason, a comment the assignee had not seen): with the ack when any.
|
|
11063
|
+
const noted = (a) => { const notes = db.board.takeNotes(); return notes.length ? { ...a, notes } : a; };
|
|
10397
11064
|
try {
|
|
11065
|
+
// The task board's own actions and fields (task-board-tool.ts); the rest is the tool's plain task, below.
|
|
11066
|
+
const onBoard = boardToolAction(db, instance, args);
|
|
11067
|
+
if (onBoard !== undefined)
|
|
11068
|
+
return onBoard;
|
|
10398
11069
|
switch (action) {
|
|
10399
11070
|
case "create": {
|
|
10400
11071
|
const title = asStr(args.title);
|
|
@@ -10433,24 +11104,24 @@ export class FleetManager {
|
|
|
10433
11104
|
const id = asStr(args.id);
|
|
10434
11105
|
if (!id)
|
|
10435
11106
|
return { error: "id is required" };
|
|
10436
|
-
return ack(db.claimTask(db.getTaskByPrefix(id).id, instance));
|
|
11107
|
+
return noted(ack(db.claimTask(db.getTaskByPrefix(id).id, instance, { kind: "instance", name: instance })));
|
|
10437
11108
|
}
|
|
10438
11109
|
case "done": {
|
|
10439
11110
|
const id = asStr(args.id);
|
|
10440
11111
|
if (!id)
|
|
10441
11112
|
return { error: "id is required" };
|
|
10442
|
-
return ack(db.completeTask(db.getTaskByPrefix(id).id, asStr(args.result)));
|
|
11113
|
+
return noted(ack(db.completeTask(db.getTaskByPrefix(id).id, asStr(args.result), { kind: "instance", name: instance })));
|
|
10443
11114
|
}
|
|
10444
11115
|
case "update": {
|
|
10445
11116
|
const id = asStr(args.id);
|
|
10446
11117
|
if (!id)
|
|
10447
11118
|
return { error: "id is required" };
|
|
10448
|
-
return ack(db.updateTask(db.getTaskByPrefix(id).id, {
|
|
11119
|
+
return noted(ack(db.updateTask(db.getTaskByPrefix(id).id, {
|
|
10449
11120
|
status: asStatus(args.status),
|
|
10450
11121
|
assignee: asStr(args.assignee),
|
|
10451
11122
|
result: asStr(args.result),
|
|
10452
11123
|
priority: asPriority(args.priority),
|
|
10453
|
-
}));
|
|
11124
|
+
}, { kind: "instance", name: instance })));
|
|
10454
11125
|
}
|
|
10455
11126
|
default: return { error: `Unknown task action: ${action}` };
|
|
10456
11127
|
}
|
|
@@ -10482,6 +11153,8 @@ export class FleetManager {
|
|
|
10482
11153
|
}
|
|
10483
11154
|
/** #1629: `agend-agent theme …` — the same service as the tools; `instance` is the token's. */
|
|
10484
11155
|
async handleWebThemeHttp(instance, tool, args) {
|
|
11156
|
+
if (isCanvasWorkerTool(tool) && !this.canvasWorkerEnabled())
|
|
11157
|
+
return { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE };
|
|
10485
11158
|
if (WEB_EFFECT_TOOLS.has(tool)) {
|
|
10486
11159
|
if (!this.isKnownInstance(instance))
|
|
10487
11160
|
return { error: "unknown_instance" };
|
|
@@ -10544,7 +11217,18 @@ export class FleetManager {
|
|
|
10544
11217
|
// so " \t " / [" ","\t"] are treated identically to undefined.
|
|
10545
11218
|
const asStatusFilter = normalizeStatusFilter;
|
|
10546
11219
|
const ack = (t) => ({ id: t.id, status: t.status, updated_at: t.updated_at });
|
|
11220
|
+
const noted = (a) => { const notes = db.board.takeNotes(); return notes.length ? { ...a, notes } : a; };
|
|
10547
11221
|
try {
|
|
11222
|
+
// The task board's own actions and fields (task-board-tool.ts); the rest is the tool's plain task, below.
|
|
11223
|
+
const onBoard = boardToolAction(db, meta.instance_name || instanceName, payload);
|
|
11224
|
+
if (onBoard !== undefined) {
|
|
11225
|
+
ipc.send({ type: "fleet_task_response", fleetRequestId, result: onBoard });
|
|
11226
|
+
// The activity log hears of board work as it does of plain tasks.
|
|
11227
|
+
const what = boardActivity(payload);
|
|
11228
|
+
if (what)
|
|
11229
|
+
this.eventLog?.logActivity("task_update", instanceName, what);
|
|
11230
|
+
return;
|
|
11231
|
+
}
|
|
10548
11232
|
let result;
|
|
10549
11233
|
// Full task kept for activity logging; the IPC reply uses the compact ack.
|
|
10550
11234
|
let logTask;
|
|
@@ -10580,12 +11264,12 @@ export class FleetManager {
|
|
|
10580
11264
|
result = db.getTaskByPrefix(payload.id);
|
|
10581
11265
|
break;
|
|
10582
11266
|
case "claim":
|
|
10583
|
-
logTask = db.claimTask(db.getTaskByPrefix(payload.id).id, meta.instance_name || instanceName);
|
|
10584
|
-
result = ack(logTask);
|
|
11267
|
+
logTask = db.claimTask(db.getTaskByPrefix(payload.id).id, meta.instance_name || instanceName, { kind: "instance", name: meta.instance_name || instanceName });
|
|
11268
|
+
result = noted(ack(logTask));
|
|
10585
11269
|
break;
|
|
10586
11270
|
case "done":
|
|
10587
|
-
logTask = db.completeTask(db.getTaskByPrefix(payload.id).id, payload.result);
|
|
10588
|
-
result = ack(logTask);
|
|
11271
|
+
logTask = db.completeTask(db.getTaskByPrefix(payload.id).id, payload.result, { kind: "instance", name: meta.instance_name || instanceName });
|
|
11272
|
+
result = noted(ack(logTask));
|
|
10589
11273
|
break;
|
|
10590
11274
|
case "update":
|
|
10591
11275
|
logTask = db.updateTask(db.getTaskByPrefix(payload.id).id, {
|
|
@@ -10593,8 +11277,8 @@ export class FleetManager {
|
|
|
10593
11277
|
assignee: payload.assignee,
|
|
10594
11278
|
result: payload.result,
|
|
10595
11279
|
priority: payload.priority,
|
|
10596
|
-
});
|
|
10597
|
-
result = ack(logTask);
|
|
11280
|
+
}, { kind: "instance", name: meta.instance_name || instanceName });
|
|
11281
|
+
result = noted(ack(logTask));
|
|
10598
11282
|
break;
|
|
10599
11283
|
default:
|
|
10600
11284
|
throw new Error(`Unknown task action: ${action}`);
|
|
@@ -10909,6 +11593,7 @@ export class FleetManager {
|
|
|
10909
11593
|
saveFleetConfig(explicitPatches = []) {
|
|
10910
11594
|
this.publicWebLink?.refresh();
|
|
10911
11595
|
this.enforceGuestSwitch(); // #1920: a Settings save turning guests off ends their sessions
|
|
11596
|
+
this.observeWebAuthority(); // #2453 review: so is a web-authority switch (off is always seen)
|
|
10912
11597
|
if (!this.fleetConfig || !this.configPath)
|
|
10913
11598
|
return;
|
|
10914
11599
|
if (!this.savedFleetConfigSnapshot)
|
|
@@ -10921,6 +11606,10 @@ export class FleetManager {
|
|
|
10921
11606
|
throw new Error(`Refusing to overwrite invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
|
|
10922
11607
|
}
|
|
10923
11608
|
this.rawFleetConfig = loadRawFleetConfig(this.configPath);
|
|
11609
|
+
// #2208: what the file says before this save, aliases expanded — every write below is recorded, and anything that
|
|
11610
|
+
// changes outside them (a place sharing a node with one written) refuses the save.
|
|
11611
|
+
const fileBefore = this.rawFleetDocument.toJS({ maxAliasCount: -1 });
|
|
11612
|
+
const writes = this.fleetDocumentWrites = []; // a throw below leaves it to the next save
|
|
10924
11613
|
this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
|
|
10925
11614
|
// Settings edits are expressed against the raw config, so apply them before
|
|
10926
11615
|
// canonicalization. A non-identity value equal to its inherited default is
|
|
@@ -10931,6 +11620,7 @@ export class FleetManager {
|
|
|
10931
11620
|
// (or a legacy scalar occupies that parent). Removing an override which
|
|
10932
11621
|
// is already absent is an idempotent no-op, not a failed Settings save.
|
|
10933
11622
|
if (this.rawFleetDocument.hasIn(patch.path)) {
|
|
11623
|
+
this.writingFleetDocument(this.rawFleetDocument, patch.path);
|
|
10934
11624
|
this.rawFleetDocument.deleteIn(patch.path);
|
|
10935
11625
|
}
|
|
10936
11626
|
}
|
|
@@ -10942,8 +11632,10 @@ export class FleetManager {
|
|
|
10942
11632
|
const rawAfterPatches = this.rawFleetDocument.toJS();
|
|
10943
11633
|
const redundantPaths = collectRedundantInstanceDefaultPaths(rawAfterPatches);
|
|
10944
11634
|
for (const path of redundantPaths) {
|
|
10945
|
-
if (this.rawFleetDocument.hasIn(path))
|
|
11635
|
+
if (this.rawFleetDocument.hasIn(path)) {
|
|
11636
|
+
this.writingFleetDocument(this.rawFleetDocument, path);
|
|
10946
11637
|
this.rawFleetDocument.deleteIn(path);
|
|
11638
|
+
}
|
|
10947
11639
|
// Avoid leaving empty operational maps such as `terminal: {}` while
|
|
10948
11640
|
// preserving the instance mapping itself and all surrounding comments.
|
|
10949
11641
|
for (let depth = path.length - 1; depth > 2; depth--) {
|
|
@@ -10951,9 +11643,14 @@ export class FleetManager {
|
|
|
10951
11643
|
const parent = this.rawFleetDocument.getIn(parentPath, true);
|
|
10952
11644
|
if (!isMap(parent) || parent.items.length > 0)
|
|
10953
11645
|
break;
|
|
11646
|
+
this.writingFleetDocument(this.rawFleetDocument, parentPath);
|
|
10954
11647
|
this.rawFleetDocument.deleteIn(parentPath);
|
|
10955
11648
|
}
|
|
10956
11649
|
}
|
|
11650
|
+
this.fleetDocumentWrites = null;
|
|
11651
|
+
const stray = changedOutside(fileBefore, this.rawFleetDocument.toJS({ maxAliasCount: -1 }), writes);
|
|
11652
|
+
if (stray)
|
|
11653
|
+
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.`);
|
|
10957
11654
|
noteSettingsWrite(this.configPath, this.savedFleetConfigSnapshot, this.fleetConfig);
|
|
10958
11655
|
const output = String(this.rawFleetDocument);
|
|
10959
11656
|
if (redundantPaths.length > 0)
|
|
@@ -11064,11 +11761,15 @@ export class FleetManager {
|
|
|
11064
11761
|
// Remove from the end so YAML sequence indexes do not shift underneath us.
|
|
11065
11762
|
for (let i = before.length - 1; i >= after.length; i--) {
|
|
11066
11763
|
const itemPath = [...path, i];
|
|
11067
|
-
if (document.hasIn(itemPath))
|
|
11764
|
+
if (document.hasIn(itemPath)) {
|
|
11765
|
+
this.writingFleetDocument(document, itemPath);
|
|
11068
11766
|
document.deleteIn(itemPath);
|
|
11767
|
+
}
|
|
11069
11768
|
}
|
|
11070
|
-
for (let i = shared; i < after.length; i++)
|
|
11769
|
+
for (let i = shared; i < after.length; i++) {
|
|
11770
|
+
this.writingFleetDocument(document, [...path, i]);
|
|
11071
11771
|
document.setIn([...path, i], after[i]);
|
|
11772
|
+
}
|
|
11072
11773
|
return;
|
|
11073
11774
|
}
|
|
11074
11775
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -11084,6 +11785,7 @@ export class FleetManager {
|
|
|
11084
11785
|
if (isMap(fresh)) {
|
|
11085
11786
|
const list = document.createNode([]);
|
|
11086
11787
|
list.items.push(fresh.clone());
|
|
11788
|
+
this.writingFleetDocument(document, ["channels"]);
|
|
11087
11789
|
document.setIn(["channels"], list);
|
|
11088
11790
|
}
|
|
11089
11791
|
}
|
|
@@ -11105,13 +11807,17 @@ export class FleetManager {
|
|
|
11105
11807
|
// Effective config contains inherited objects that may not exist in the
|
|
11106
11808
|
// raw YAML at all. yaml.deleteIn() is not idempotent for a missing nested
|
|
11107
11809
|
// parent, so guard it explicitly.
|
|
11108
|
-
if (document.hasIn(path))
|
|
11810
|
+
if (document.hasIn(path)) {
|
|
11811
|
+
this.writingFleetDocument(document, path);
|
|
11109
11812
|
document.deleteIn(path);
|
|
11813
|
+
}
|
|
11110
11814
|
}
|
|
11111
11815
|
else if (path.length === 0) {
|
|
11816
|
+
this.writingFleetDocument(document, path);
|
|
11112
11817
|
document.contents = document.createNode(after);
|
|
11113
11818
|
}
|
|
11114
11819
|
else {
|
|
11820
|
+
this.writingFleetDocument(document, path);
|
|
11115
11821
|
const currentNode = document.getIn(path, true);
|
|
11116
11822
|
if (isScalar(currentNode) && (after === null || typeof after !== "object")) {
|
|
11117
11823
|
currentNode.value = after;
|
|
@@ -11126,6 +11832,15 @@ export class FleetManager {
|
|
|
11126
11832
|
}
|
|
11127
11833
|
}
|
|
11128
11834
|
}
|
|
11835
|
+
/**
|
|
11836
|
+
* #2208: before every write to the raw document — the place is unshared first (an alias on the way becomes its own
|
|
11837
|
+
* copy; an anchor's other aliases get theirs), so the write changes that place only; and it is recorded for the
|
|
11838
|
+
* save's check that nothing else changed.
|
|
11839
|
+
*/
|
|
11840
|
+
writingFleetDocument(document, path) {
|
|
11841
|
+
detachForWrite(document, path);
|
|
11842
|
+
this.fleetDocumentWrites?.push([...path]);
|
|
11843
|
+
}
|
|
11129
11844
|
getSettingsConfigPath() { return this.configPath; }
|
|
11130
11845
|
async removeInstance(name, authorization, execution) {
|
|
11131
11846
|
if (execution)
|
|
@@ -11169,10 +11884,27 @@ export class FleetManager {
|
|
|
11169
11884
|
// Capture instance dir BEFORE lifecycle.remove, which deletes the instance
|
|
11170
11885
|
// from fleetConfig. (The backend would be unresolvable afterwards.)
|
|
11171
11886
|
const instanceDirToClean = this.getInstanceDir(name);
|
|
11172
|
-
|
|
11173
|
-
|
|
11174
|
-
|
|
11175
|
-
|
|
11887
|
+
// #1774: replies sent from here on are a later instance's (a replacement registered while this one goes); the ones
|
|
11888
|
+
// sent before wait for the outcome before they are shown.
|
|
11889
|
+
const removedBelow = this.webChatGeneration(name) + 1, removedDaemon = this.daemons.get(name);
|
|
11890
|
+
this.webChatGenerations.set(name, removedBelow);
|
|
11891
|
+
let settleRemoval;
|
|
11892
|
+
const removalPending = { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}), done: new Promise(r => { settleRemoval = r; }) };
|
|
11893
|
+
this.webChatRemovalsPending.set(name, removalPending);
|
|
11894
|
+
try {
|
|
11895
|
+
if (execution)
|
|
11896
|
+
await this.lifecycle.remove(name, authorization, execution, transition);
|
|
11897
|
+
else
|
|
11898
|
+
await this.lifecycle.remove(name, authorization);
|
|
11899
|
+
// #1774: removed — its replies still copying their files are no one's to show, replaced or not (a failed removal
|
|
11900
|
+
// throws above: its replies stay its own).
|
|
11901
|
+
this.webChatRemovals.set(name, { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}) });
|
|
11902
|
+
}
|
|
11903
|
+
finally {
|
|
11904
|
+
if (this.webChatRemovalsPending.get(name) === removalPending)
|
|
11905
|
+
this.webChatRemovalsPending.delete(name);
|
|
11906
|
+
settleRemoval();
|
|
11907
|
+
}
|
|
11176
11908
|
// A later same-name registration owns its files and caches.
|
|
11177
11909
|
// Own keys only: an instance named "constructor" is removed as any other (its inherited namesake is not a
|
|
11178
11910
|
// re-registration) — #1677 review.
|
|
@@ -11187,6 +11919,7 @@ export class FleetManager {
|
|
|
11187
11919
|
// Its web chat goes with it (only after the removal succeeded): a later instance of the same name
|
|
11188
11920
|
// must not be shown the old one's conversation, and deleted names must not pile up.
|
|
11189
11921
|
this.webChatHistory.forget(name);
|
|
11922
|
+
this.searchIndex?.removeInstance(name);
|
|
11190
11923
|
this.webFiles.forget(name);
|
|
11191
11924
|
this.compactWatchService?.forget(name);
|
|
11192
11925
|
// #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
|
|
@@ -11398,11 +12131,23 @@ export class FleetManager {
|
|
|
11398
12131
|
guildEmojiFetches = new Map();
|
|
11399
12132
|
// ── #1630: what stands for an instance in the web app, and Discord server emoji in web chat ──
|
|
11400
12133
|
webImageCaches = null;
|
|
12134
|
+
/** Server emoji whose picture fetch failure was already logged at warn (once each; bounded). */
|
|
12135
|
+
webEmojiFailures = new Set();
|
|
11401
12136
|
webImageCache(kind) {
|
|
11402
12137
|
const onError = (key, err) => this.logger.debug({ err: err?.message, key }, "web image fetch failed (kept what was cached)");
|
|
12138
|
+
// A server emoji's picture that cannot be fetched reads `:name:` in web chat (it used to be an empty box, the user's
|
|
12139
|
+
// "tofu"): say why once per emoji, where it can be seen, not on every render (its key is quiet for an hour anyway).
|
|
12140
|
+
const emojiError = (key, err) => {
|
|
12141
|
+
if (this.webEmojiFailures.has(key))
|
|
12142
|
+
return onError(key, err);
|
|
12143
|
+
this.webEmojiFailures.add(key);
|
|
12144
|
+
while (this.webEmojiFailures.size > 512)
|
|
12145
|
+
this.webEmojiFailures.delete(this.webEmojiFailures.values().next().value);
|
|
12146
|
+
this.logger.warn({ err: err?.message, key }, "Discord server emoji picture could not be fetched (web chat shows its :name:)");
|
|
12147
|
+
};
|
|
11403
12148
|
this.webImageCaches ??= {
|
|
11404
12149
|
avatars: new WebImageCache({ dir: join(this.dataDir, "cache", "avatars"), onError }),
|
|
11405
|
-
emoji: new WebImageCache({ dir: join(this.dataDir, "cache", "emoji"), onError }),
|
|
12150
|
+
emoji: new WebImageCache({ dir: join(this.dataDir, "cache", "emoji"), onError: emojiError }),
|
|
11406
12151
|
};
|
|
11407
12152
|
return this.webImageCaches[kind];
|
|
11408
12153
|
}
|
|
@@ -11855,7 +12600,7 @@ export class FleetManager {
|
|
|
11855
12600
|
/** A preview under the inbox, named by a hash of the id (a Telegram file_id is long), pruned after a day. */
|
|
11856
12601
|
storeStickerPreview(id, picture) {
|
|
11857
12602
|
const dir = join(this.dataDir, "inbox", "sticker-previews");
|
|
11858
|
-
mkdirSync(dir, { recursive: true });
|
|
12603
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
11859
12604
|
const now = Date.now();
|
|
11860
12605
|
for (const name of readdirSync(dir)) {
|
|
11861
12606
|
try {
|
|
@@ -11921,7 +12666,7 @@ export class FleetManager {
|
|
|
11921
12666
|
if (!/^\d{15,25}$/.test(id))
|
|
11922
12667
|
throw new Error("not a Discord emoji id");
|
|
11923
12668
|
const dir = join(this.dataDir, "inbox", "emoji-previews");
|
|
11924
|
-
mkdirSync(dir, { recursive: true });
|
|
12669
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
11925
12670
|
const now = Date.now();
|
|
11926
12671
|
for (const name of readdirSync(dir)) {
|
|
11927
12672
|
try {
|
|
@@ -12320,7 +13065,7 @@ export class FleetManager {
|
|
|
12320
13065
|
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
|
12321
13066
|
const backend = this.fleetConfig?.defaults?.backend || "claude-code";
|
|
12322
13067
|
try {
|
|
12323
|
-
const { execFile } = await import("
|
|
13068
|
+
const { execFile } = await import("./child-process.js");
|
|
12324
13069
|
const { promisify } = await import("node:util");
|
|
12325
13070
|
// execFile with an argv array — no shell, so the backend name cannot be
|
|
12326
13071
|
// interpreted as a command even if config is malformed.
|
|
@@ -12546,6 +13291,51 @@ export class FleetManager {
|
|
|
12546
13291
|
}
|
|
12547
13292
|
return { chatId, opts: {} };
|
|
12548
13293
|
}
|
|
13294
|
+
/**
|
|
13295
|
+
* #2246 step 3: the task board's daily summary of [since, until) (task-board-summary.ts). Each chat world's General
|
|
13296
|
+
* gets its own instances' cards — a card that no world's instance holds (nobody's, a person's) goes to the primary
|
|
13297
|
+
* one — and each of that world's Generals shows the same text in the web chat. A world with nothing to say gets
|
|
13298
|
+
* nothing. A web-only fleet: everything, in the web chat.
|
|
13299
|
+
*/
|
|
13300
|
+
postTaskBoardSummary(since, until) {
|
|
13301
|
+
const board = this.scheduler?.db.board;
|
|
13302
|
+
if (!board)
|
|
13303
|
+
return;
|
|
13304
|
+
const read = board.summaryRead(since.toISOString(), until.toISOString(), this.dispatcherGone);
|
|
13305
|
+
const projects = board.listProjects(true);
|
|
13306
|
+
const stats = board.projectStats();
|
|
13307
|
+
const names = new Map(projects.map((p) => [p.id, p.name]));
|
|
13308
|
+
const configured = this.getPrimaryAdapterId();
|
|
13309
|
+
const primary = configured !== undefined && this.worlds.has(configured) ? configured : this.worlds.keys().next().value;
|
|
13310
|
+
const worldOf = (assignee) => {
|
|
13311
|
+
const owner = assignee ? this.getInstanceAdapterId(assignee) : undefined;
|
|
13312
|
+
return owner !== undefined && this.worlds.has(owner) ? owner : primary;
|
|
13313
|
+
};
|
|
13314
|
+
const zone = this.usageZone();
|
|
13315
|
+
for (const world of this.worlds.size ? [...this.worlds.keys()] : [undefined]) {
|
|
13316
|
+
const summary = buildSummary(read, since, until, { inChat: (assignee) => worldOf(assignee) === world, projects, stats });
|
|
13317
|
+
if (isQuiet(summary))
|
|
13318
|
+
continue;
|
|
13319
|
+
const text = renderSummary(summary, { zone, t: (key, ...args) => t(key, ...args), projectOf: (c) => (c.projectId === null ? null : names.get(c.projectId) ?? null) });
|
|
13320
|
+
const cards = [...new Set([...summary.review, ...summary.blocked, ...summary.done, ...summary.created].map((c) => c.code))];
|
|
13321
|
+
if (world !== undefined) {
|
|
13322
|
+
const adapter = this.worlds.get(world)?.adapter;
|
|
13323
|
+
const target = this.fleetNoticeTarget(world);
|
|
13324
|
+
if (adapter && target) {
|
|
13325
|
+
adapter.sendText(target.chatId, text, target.opts).catch(e => this.logger.warn({ err: e, world }, "Failed to send the task board's daily summary"));
|
|
13326
|
+
}
|
|
13327
|
+
else {
|
|
13328
|
+
this.logger.warn({ world }, "The task board's daily summary has no postable target — set a General topic or channel.options.general_channel_id");
|
|
13329
|
+
}
|
|
13330
|
+
}
|
|
13331
|
+
for (const [name, cfg] of Object.entries(this.fleetConfig?.instances ?? {})) {
|
|
13332
|
+
if (cfg.general_topic !== true || (world !== undefined && this.getInstanceAdapterId(name) !== world))
|
|
13333
|
+
continue;
|
|
13334
|
+
// #2341 review: with the cards it names, so the public link withholds it while one of them is hidden there.
|
|
13335
|
+
this.emitSseEvent("message", { instance: name, sender: name, role: "status", text, ts: new Date().toISOString(), cards });
|
|
13336
|
+
}
|
|
13337
|
+
}
|
|
13338
|
+
}
|
|
12549
13339
|
/**
|
|
12550
13340
|
* Post the daily summary where fleet-wide notices go.
|
|
12551
13341
|
*
|
|
@@ -13192,6 +13982,7 @@ export class FleetManager {
|
|
|
13192
13982
|
interactionSummary: p.interaction_summary ?? null,
|
|
13193
13983
|
pauseReason, pausedAt,
|
|
13194
13984
|
crashedAt: crashed ? (this.needsCrashedAt.get(name) ?? null) : null,
|
|
13985
|
+
signIn: pauseReason === "auth" ? this.signInFor(name) : null,
|
|
13195
13986
|
});
|
|
13196
13987
|
}
|
|
13197
13988
|
return out;
|
|
@@ -13253,6 +14044,8 @@ export class FleetManager {
|
|
|
13253
14044
|
return String(topic);
|
|
13254
14045
|
return this.classicChannels?.getChannelIdByInstance(instance) ?? undefined;
|
|
13255
14046
|
},
|
|
14047
|
+
laneTarget: instance => this.instanceChannelTarget(instance),
|
|
14048
|
+
taskRoute: code => this.needsYouRoute(code),
|
|
13256
14049
|
isFleetAdmin: (userId, world) => this.isFleetAdmin(userId, world),
|
|
13257
14050
|
fleetAdmins: world => (this.getChannelConfig(world)?.access?.allowed_users ?? []).map(String),
|
|
13258
14051
|
emitSse: (event, data) => this.emitSseEvent(event, data),
|
|
@@ -13261,6 +14054,8 @@ export class FleetManager {
|
|
|
13261
14054
|
throw new Error("the delivery outbox is not open");
|
|
13262
14055
|
return this.deliveryOutbox.acknowledge(deliveryId, by);
|
|
13263
14056
|
},
|
|
14057
|
+
tasks: () => this.needsYouTaskItems(),
|
|
14058
|
+
taskAction: action => this.taskActionFromChat(action),
|
|
13264
14059
|
settings: () => ({
|
|
13265
14060
|
liveMessage: this.fleetConfig?.needs_you?.live_message !== false,
|
|
13266
14061
|
dm: this.fleetConfig?.needs_you?.dm === true,
|
|
@@ -13271,6 +14066,114 @@ export class FleetManager {
|
|
|
13271
14066
|
});
|
|
13272
14067
|
this.needsYou.start();
|
|
13273
14068
|
}
|
|
14069
|
+
/**
|
|
14070
|
+
* #2352: where an instance's own notices go — its topic in its world's group, or its ClassicBot room — as a Needs you
|
|
14071
|
+
* lane's place; null when it has neither (or its world has no group).
|
|
14072
|
+
*/
|
|
14073
|
+
instanceChannelTarget(instance) {
|
|
14074
|
+
const cfg = this.fleetConfig?.instances && Object.hasOwn(this.fleetConfig.instances, instance) ? this.fleetConfig.instances[instance] : undefined;
|
|
14075
|
+
if (!cfg)
|
|
14076
|
+
return null;
|
|
14077
|
+
const groupId = this.getChannelConfig(this.getInstanceAdapterId(instance))?.group_id;
|
|
14078
|
+
if (cfg.topic_id != null && groupId != null)
|
|
14079
|
+
return { chatId: String(groupId), threadId: String(cfg.topic_id) };
|
|
14080
|
+
const classic = this.classicChannels?.getChannelIdByInstance(instance);
|
|
14081
|
+
return classic ? { chatId: classic } : null;
|
|
14082
|
+
}
|
|
14083
|
+
/**
|
|
14084
|
+
* #2352: the instance whose own channel lists a waiting card — its project's lead — when that channel can be used now
|
|
14085
|
+
* (a fleet instance of a live world, with a topic or room). undefined: its assignee's world's General, as before.
|
|
14086
|
+
* #2434: only a card the person handed out waits for them, so the instance that handed one out is never its lane.
|
|
14087
|
+
*/
|
|
14088
|
+
needsYouLane(card, leads) {
|
|
14089
|
+
const usable = (name) => {
|
|
14090
|
+
if (!name || !this.fleetConfig?.instances || !Object.hasOwn(this.fleetConfig.instances, name))
|
|
14091
|
+
return false;
|
|
14092
|
+
const world = this.getInstanceAdapterId(name);
|
|
14093
|
+
return world !== undefined && this.worlds.has(world) && this.instanceChannelTarget(name) !== null;
|
|
14094
|
+
};
|
|
14095
|
+
const lead = card.projectId != null ? leads.get(card.projectId) : null;
|
|
14096
|
+
return usable(lead) ? lead : undefined;
|
|
14097
|
+
}
|
|
14098
|
+
/**
|
|
14099
|
+
* #2355 review: where a card's Needs you buttons belong now — read from the board and the fleet at the moment of a click
|
|
14100
|
+
* or a reply, never from the last render. null: it no longer waits for the person.
|
|
14101
|
+
*/
|
|
14102
|
+
/**
|
|
14103
|
+
* #2425 follow-up: an instance that handed out a card and is no longer in the fleet — neither a configured instance nor
|
|
14104
|
+
* a ClassicBot identity (deleted). Its blocked and in-review cards fall back to the person (Needs you, the summary).
|
|
14105
|
+
* Not known before the config and the ClassicBot rooms are loaded: then no one is gone (a live dispatcher's cards never
|
|
14106
|
+
* flash into the person's list at startup).
|
|
14107
|
+
*/
|
|
14108
|
+
dispatcherGone = (name) => {
|
|
14109
|
+
const instances = this.fleetConfig?.instances, classic = this.classicChannels;
|
|
14110
|
+
if (!instances || !classic)
|
|
14111
|
+
return false;
|
|
14112
|
+
return !Object.hasOwn(instances, name) && !classic.getAll().some((ch) => ch.instanceName === name);
|
|
14113
|
+
};
|
|
14114
|
+
needsYouRoute(code) {
|
|
14115
|
+
const board = this.scheduler?.db.board;
|
|
14116
|
+
const card = board?.findCard(code);
|
|
14117
|
+
// #2425 (Fable): the same rule as the list — a button rendered before the fix (or before the opt-in went off) for a
|
|
14118
|
+
// card that no longer waits for the person answers "no longer waits".
|
|
14119
|
+
if (!board || !card || this.fleetConfig?.needs_you?.task_board !== true || !waitsForPerson(card, this.dispatcherGone))
|
|
14120
|
+
return null;
|
|
14121
|
+
const leads = new Map(board.listProjects(true).map((p) => [p.id, p.leadInstance]));
|
|
14122
|
+
const lane = this.needsYouLane(card, leads);
|
|
14123
|
+
return lane ? { lane } : {};
|
|
14124
|
+
}
|
|
14125
|
+
/**
|
|
14126
|
+
* #2246 step 3: the task board's cards that wait for the person, for Needs you — under each card's assignee, listed in
|
|
14127
|
+
* its lane's channel (#2352: its project's lead; #2434: never the instance that handed it out), else in General.
|
|
14128
|
+
*/
|
|
14129
|
+
needsYouTaskItems() {
|
|
14130
|
+
// #2425: the task board's cards join Needs you only when the fleet asks for them (needs_you.task_board, off by
|
|
14131
|
+
// default since 2.3.0-beta.25); prompts, deliveries and the rest are unchanged.
|
|
14132
|
+
return this.fleetConfig?.needs_you?.task_board === true ? this.needsYouTasks() : [];
|
|
14133
|
+
}
|
|
14134
|
+
needsYouTasks() {
|
|
14135
|
+
const board = this.scheduler?.db.board;
|
|
14136
|
+
if (!board)
|
|
14137
|
+
return [];
|
|
14138
|
+
const leads = new Map(board.listProjects(true).map((p) => [p.id, p.leadInstance]));
|
|
14139
|
+
return board.waitingForPerson(this.dispatcherGone).map(({ card, since }) => {
|
|
14140
|
+
const gap = card.status === "review" ? wrapUpGap(card) : null;
|
|
14141
|
+
const lane = this.needsYouLane(card, leads);
|
|
14142
|
+
return {
|
|
14143
|
+
uuid: card.uuid, code: card.code, title: card.title, status: card.status, version: card.version,
|
|
14144
|
+
assignee: card.assignee, since: Date.parse(since) || 0, ...(gap ? { gap } : {}), ...(lane ? { lane } : {}),
|
|
14145
|
+
note: card.status === "blocked" ? card.blockedReason : gap ? t("needs.task_gap", [
|
|
14146
|
+
gap.unchecked ? t("needs.task_gap_unchecked", gap.unchecked) : "", gap.noResult ? t("needs.task_gap_no_result") : "",
|
|
14147
|
+
].filter(Boolean).join(t("needs.task_gap_join"))) : "",
|
|
14148
|
+
};
|
|
14149
|
+
});
|
|
14150
|
+
}
|
|
14151
|
+
/**
|
|
14152
|
+
* #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
|
|
14153
|
+
* hub), acting as the person, through the board's rules, at the version the button showed.
|
|
14154
|
+
*/
|
|
14155
|
+
taskActionFromChat(action) {
|
|
14156
|
+
const board = this.scheduler?.db.board;
|
|
14157
|
+
if (!board)
|
|
14158
|
+
return { ok: false, message: t("needs.task_board_closed") };
|
|
14159
|
+
const person = { kind: "person", name: action.by.username || `${action.by.platform}:${action.by.userId}` };
|
|
14160
|
+
try {
|
|
14161
|
+
if (action.verb === "confirm")
|
|
14162
|
+
board.moveCard(person, action.code, "done", { version: action.version });
|
|
14163
|
+
else if (action.verb === "back")
|
|
14164
|
+
board.moveCard(person, action.code, "claimed", { version: action.version, reason: action.text });
|
|
14165
|
+
else
|
|
14166
|
+
board.moveCard(person, action.code, "claimed", { version: action.version, comment: action.text });
|
|
14167
|
+
const done = { confirm: "needs.task_confirmed", back: "needs.task_sent_back", answer: "needs.task_answered" }[action.verb];
|
|
14168
|
+
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");
|
|
14169
|
+
return { ok: true, message: t(done, action.code) };
|
|
14170
|
+
}
|
|
14171
|
+
catch (err) {
|
|
14172
|
+
if (err instanceof TaskBoardError && err.code === "conflict")
|
|
14173
|
+
return { ok: false, message: t("needs.task_changed", action.code) };
|
|
14174
|
+
return { ok: false, message: t("needs.task_refused", action.code, err.message) };
|
|
14175
|
+
}
|
|
14176
|
+
}
|
|
13274
14177
|
/** The prompts open on the dashboard right now (a page that loads after one was posted asks for them). */
|
|
13275
14178
|
listWebPrompts() {
|
|
13276
14179
|
const open = [];
|
|
@@ -13668,7 +14571,7 @@ export class FleetManager {
|
|
|
13668
14571
|
...(input.instance !== undefined ? { target: { adapterId: this.getInstanceAdapterId(input.instance) } } : {}),
|
|
13669
14572
|
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
13670
14573
|
authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
|
|
13671
|
-
policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now: Date.now(),
|
|
14574
|
+
policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now: Date.now(),
|
|
13672
14575
|
});
|
|
13673
14576
|
}
|
|
13674
14577
|
sessionAuthorizations = null;
|
|
@@ -13693,7 +14596,7 @@ export class FleetManager {
|
|
|
13693
14596
|
session, gateway, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
13694
14597
|
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
13695
14598
|
authorization: { handle: session.handle, owner: session.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
|
|
13696
|
-
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now,
|
|
14599
|
+
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
|
|
13697
14600
|
});
|
|
13698
14601
|
if (check.refusal)
|
|
13699
14602
|
return { ok: false, refusal: check.refusal };
|
|
@@ -13708,9 +14611,30 @@ export class FleetManager {
|
|
|
13708
14611
|
*/
|
|
13709
14612
|
webAuthorityState() {
|
|
13710
14613
|
if (!this.dataDir)
|
|
13711
|
-
return { enabled: false, loopbackOnly: true, epoch: 0 };
|
|
14614
|
+
return { enabled: false, loopbackOnly: true, namedLink: false, epoch: 0 };
|
|
13712
14615
|
this.authorityStateStore ??= new AuthorityStateStore(join(this.dataDir, "web-authority.json"), this.logger, undefined, undefined, () => { this.ensureDeliveryOutbox?.(); return this.deliveryOutbox?.maxWebAuthorityEpoch() ?? 0; });
|
|
13713
|
-
|
|
14616
|
+
const state = this.authorityStateStore.current(this.fleetConfig?.web?.verified_authority);
|
|
14617
|
+
// #2453 (§0.2): the named-link option seen going off — the authority of every session over the link ends now, for
|
|
14618
|
+
// good: their authorizations go (a later "on" can never bring one back without its account's Confirm).
|
|
14619
|
+
if (state.namedLinkClosed)
|
|
14620
|
+
this.sessionAuthorizationStore()?.prune(a => this.webSessions?.findByHandle(a.handle)?.surface === "local");
|
|
14621
|
+
return state;
|
|
14622
|
+
}
|
|
14623
|
+
/**
|
|
14624
|
+
* #2453 review: the web-authority state read on every configuration change (a Settings save, the host's confirm, a
|
|
14625
|
+
* reload) — so a switch turned off and on again between two requests is still seen off: its epoch moves, and the
|
|
14626
|
+
* named-link sessions' authorizations are dropped. Reads that only happen on a request would miss it.
|
|
14627
|
+
*/
|
|
14628
|
+
observeWebAuthority() {
|
|
14629
|
+
// Only once the state is in use: any authorization was given through a read that made it (and a first read at
|
|
14630
|
+
// start-up still sees what changed while the fleet was down). Nothing new is opened during a load.
|
|
14631
|
+
try {
|
|
14632
|
+
if (this.dataDir && this.authorityStateStore)
|
|
14633
|
+
this.webAuthorityState();
|
|
14634
|
+
}
|
|
14635
|
+
catch (err) {
|
|
14636
|
+
this.logger.warn({ err: err?.message }, "Web authority state could not be read after a configuration change");
|
|
14637
|
+
}
|
|
13714
14638
|
}
|
|
13715
14639
|
/**
|
|
13716
14640
|
* #1939 S2a: Settings → Web → Web authority — the state in force (the switch counts as off while its state cannot be
|
|
@@ -13725,8 +14649,10 @@ export class FleetManager {
|
|
|
13725
14649
|
const authorized = state.enabled ? (this.sessionAuthorizationStore()?.list() ?? [])
|
|
13726
14650
|
.filter(a => a.epoch === state.epoch && sessions.has(a.handle) && now <= Math.min(a.until, a.authorizedAt + maxMs))
|
|
13727
14651
|
.map(a => ({ handle: a.handle, label: sessions.get(a.handle).label, account: `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
|
|
13728
|
-
until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry)
|
|
13729
|
-
|
|
14652
|
+
until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry),
|
|
14653
|
+
...(sessions.get(a.handle).surface === "gateway" ? { named: true } : {}) })) : [];
|
|
14654
|
+
const host = this.fleetConfig?.web?.public_link?.hostname;
|
|
14655
|
+
return { state, configured, hours: VERIFIED_SESSION_HOURS, lanHosts, authorized, namedHost: typeof host === "string" && host ? host : null };
|
|
13730
14656
|
}
|
|
13731
14657
|
authorizationRequests = null;
|
|
13732
14658
|
/**
|
|
@@ -13739,7 +14665,9 @@ export class FleetManager {
|
|
|
13739
14665
|
const adapterOf = (adapterId) => this.adapters.get(adapterId) ?? this.worlds.get(adapterId)?.adapter;
|
|
13740
14666
|
// The host's clock, with its zone: the person may read the DM on a phone in another one.
|
|
13741
14667
|
const when = (ms) => new Date(ms).toLocaleString(undefined, { timeZoneName: "short" });
|
|
13742
|
-
const where = (peer) =>
|
|
14668
|
+
const where = (peer) => peer === "named_link"
|
|
14669
|
+
? t("web_auth.peer_named_link", String(this.fleetConfig?.web?.public_link?.hostname ?? "?")) // §0.2: the internet
|
|
14670
|
+
: t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
|
|
13743
14671
|
return this.authorizationRequests = new SessionAuthorizationRequests({
|
|
13744
14672
|
now: () => performance.now(), wallNow: () => Date.now(),
|
|
13745
14673
|
maxSessionMs: () => webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs,
|
|
@@ -13773,7 +14701,8 @@ export class FleetManager {
|
|
|
13773
14701
|
const record = this.webSessions?.findByHandle(handle);
|
|
13774
14702
|
if (!record)
|
|
13775
14703
|
return { ok: false, refusal: "session_gone" };
|
|
13776
|
-
|
|
14704
|
+
// §0.2: a named-link session is judged as the gateway request it is (its exposure still the current one).
|
|
14705
|
+
const r = this.authorizeWebSession(record, peer, peer === "named_link");
|
|
13777
14706
|
// What the card says: the end of the authorization, or the session's own end when that comes first.
|
|
13778
14707
|
return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
|
|
13779
14708
|
},
|
|
@@ -13819,7 +14748,7 @@ export class FleetManager {
|
|
|
13819
14748
|
session: s, gateway: input.gateway, peer: input.peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
|
|
13820
14749
|
isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
|
|
13821
14750
|
authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
|
|
13822
|
-
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now,
|
|
14751
|
+
policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
|
|
13823
14752
|
});
|
|
13824
14753
|
return r.refusal;
|
|
13825
14754
|
}
|
|
@@ -13902,7 +14831,7 @@ export class FleetManager {
|
|
|
13902
14831
|
const current = this.resolveInstanceEffort(instance).effort;
|
|
13903
14832
|
return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
|
|
13904
14833
|
},
|
|
13905
|
-
backendStep: async (instance, args) => {
|
|
14834
|
+
backendStep: async (instance, args, stepOpts) => {
|
|
13906
14835
|
const refusal = this.backendCommandRefusal(instance);
|
|
13907
14836
|
if (refusal)
|
|
13908
14837
|
return { error: refusal };
|
|
@@ -13912,7 +14841,13 @@ export class FleetManager {
|
|
|
13912
14841
|
return step.noop ? { text: step.text } : { error: step.text };
|
|
13913
14842
|
if (step.kind === "choose") {
|
|
13914
14843
|
const id = (token) => joinBackendArgs([...step.args, token]);
|
|
13915
|
-
return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c =>
|
|
14844
|
+
return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => {
|
|
14845
|
+
// #2233: the web offers Sign in beside it, so its reason is no longer "use /login".
|
|
14846
|
+
// Not over the public link: Sign in is refused there, so the choice keeps its "(use /login)" (#2262 review).
|
|
14847
|
+
const signIn = c.signIn && !stepOpts?.publicLink && dashboardSignInSupported(c.signIn) ? c.signIn : null;
|
|
14848
|
+
const disabled = signIn ? t("backend.reason_not_signed_in_web") : c.disabled;
|
|
14849
|
+
return { id: id(c.token), label: signIn ? `${c.token} — ${disabled}` : c.label, ...(disabled ? { disabled } : {}), ...(signIn ? { signIn } : {}) };
|
|
14850
|
+
}) } };
|
|
13916
14851
|
}
|
|
13917
14852
|
const now = performance.now();
|
|
13918
14853
|
for (const [k, v] of this.webBackendTokens)
|
|
@@ -14569,7 +15504,7 @@ export class FleetManager {
|
|
|
14569
15504
|
}
|
|
14570
15505
|
}
|
|
14571
15506
|
this.persistCancelButtons();
|
|
14572
|
-
this.logger.
|
|
15507
|
+
this.logger.debug({ instanceName, messageId: sent.messageId }, "Cancel button sent"); // #2482: per-turn bookkeeping
|
|
14573
15508
|
// Retirement is normally edge-triggered, but the edge (or an explicit
|
|
14574
15509
|
// reply/cancel clear) may have happened while notifyAlert was in flight.
|
|
14575
15510
|
// Reconcile the level after publication so a late button cannot resurrect
|
|
@@ -14835,7 +15770,8 @@ export class FleetManager {
|
|
|
14835
15770
|
this.deleteButtonMessage(entry)
|
|
14836
15771
|
.then(() => {
|
|
14837
15772
|
this.discardButton(entry);
|
|
14838
|
-
this.logger.
|
|
15773
|
+
this.logger.debug(// #2482: per-turn bookkeeping
|
|
15774
|
+
{ instanceName: entry.instanceName, messageId: entry.messageId, historyPreserved: Boolean(entry.toolProgress) }, "Cancel button retired");
|
|
14839
15775
|
})
|
|
14840
15776
|
.catch((err) => this.scheduleButtonRetry(entry, err));
|
|
14841
15777
|
}
|
|
@@ -15076,6 +16012,86 @@ export class FleetManager {
|
|
|
15076
16012
|
}
|
|
15077
16013
|
return this.loginController;
|
|
15078
16014
|
}
|
|
16015
|
+
/** #2233: sign-ins started from the web dashboard (local admin sessions), on the same controller as chat's. */
|
|
16016
|
+
webBackendLoginsInstance = null;
|
|
16017
|
+
get webBackendLogins() {
|
|
16018
|
+
this.webBackendLoginsInstance ??= new WebBackendLogins({
|
|
16019
|
+
isLocalAdmin: handle => this.isLocalAdminSession(handle),
|
|
16020
|
+
start: (backend, chat, opts) => this.startWebDashboardLogin(backend, chat, opts),
|
|
16021
|
+
accessToken: sink => this.loginController?.webAccessToken(sink) ?? null,
|
|
16022
|
+
isActiveFor: sink => this.loginController?.isActiveFor(sink) ?? false,
|
|
16023
|
+
cancelFor: sink => this.loginController?.cancelFor(sink) ?? Promise.resolve(false),
|
|
16024
|
+
cancel: () => this.cancelLoginSession(),
|
|
16025
|
+
notice: ({ shown, label, handle }) => this.notifyFleetError(t("login.web_dashboard_notice", shown, label, handle.slice(0, 8))),
|
|
16026
|
+
// No details in the event: each page reads its own view (GET /ui/backend-login), as its session may see it.
|
|
16027
|
+
changed: () => broadcastSseEvent(this.sseClients, "backend_login", {}, err => this.logger.debug({ err }, "backend_login event failed")),
|
|
16028
|
+
});
|
|
16029
|
+
return this.webBackendLoginsInstance;
|
|
16030
|
+
}
|
|
16031
|
+
/** #2233: the login a paused-for-sign-in instance needs, when the dashboard can sign it in. */
|
|
16032
|
+
signInFor(name) {
|
|
16033
|
+
const backend = this.backendNameForInstance(name);
|
|
16034
|
+
const profile = this.credentialProfileOf(name, backend);
|
|
16035
|
+
return dashboardSignInSupported(backend, profile) ? { backend, ...(profile ? { profile } : {}) } : null;
|
|
16036
|
+
}
|
|
16037
|
+
/**
|
|
16038
|
+
* #2233: Settings → Agents' sign-in list — every backend's default login as the availability snapshot has it, then
|
|
16039
|
+
* each credential profile an agent uses (its own login state, read offline).
|
|
16040
|
+
*/
|
|
16041
|
+
backendSignInTargets() {
|
|
16042
|
+
const out = [];
|
|
16043
|
+
for (const row of this.backendAvailabilityRows()) {
|
|
16044
|
+
const flow = LOGIN_FLOWS[row.backend];
|
|
16045
|
+
const install = BACKEND_INSTALLATION_INFO[row.backend]?.install;
|
|
16046
|
+
out.push({
|
|
16047
|
+
// Not installed or too old: signing in would not help — the row says how to install (or update) it instead.
|
|
16048
|
+
backend: row.backend, state: row.state, signIn: row.state !== "not_installed" && row.state !== "too_old" && dashboardSignInSupported(row.backend),
|
|
16049
|
+
...(flow?.remoteLogin === "unsupported" ? { hostCommand: flow.command } : {}),
|
|
16050
|
+
...(row.state === "not_installed" && install ? { install } : {}),
|
|
16051
|
+
...(row.state === "too_old" && install ? { update: install } : {}),
|
|
16052
|
+
});
|
|
16053
|
+
}
|
|
16054
|
+
const seen = new Set();
|
|
16055
|
+
for (const name of Object.keys(this.fleetConfig?.instances ?? {}).sort()) {
|
|
16056
|
+
const backend = this.backendNameForInstance(name);
|
|
16057
|
+
const profile = this.credentialProfileOf(name, backend);
|
|
16058
|
+
if (!profile || seen.has(`${backend}\0${profile}`))
|
|
16059
|
+
continue;
|
|
16060
|
+
seen.add(`${backend}\0${profile}`);
|
|
16061
|
+
let login = "unknown";
|
|
16062
|
+
try {
|
|
16063
|
+
login = credentialProfileLogin(this.dataDir, backend, profile).state;
|
|
16064
|
+
}
|
|
16065
|
+
catch { /* unknown */ }
|
|
16066
|
+
const signIn = dashboardSignInSupported(backend, profile);
|
|
16067
|
+
const hostCommand = signIn ? null : credentialProfileLoginCommand(this.dataDir, backend, profile);
|
|
16068
|
+
out.push({ backend, profile, state: login === "logged-in" ? "usable" : login === "signed-out" ? "not_signed_in" : "unknown", signIn,
|
|
16069
|
+
...(hostCommand ? { hostCommand } : {}) });
|
|
16070
|
+
}
|
|
16071
|
+
return out;
|
|
16072
|
+
}
|
|
16073
|
+
/** #2233: the session with this handle is live, admin, local and of the current dashboard token. */
|
|
16074
|
+
isLocalAdminSession(handle) {
|
|
16075
|
+
const sessions = this.webSessions, token = this.webToken;
|
|
16076
|
+
const record = sessions?.findByHandle(handle);
|
|
16077
|
+
return !!record && !!token && record.tier === "admin" && record.surface === "local" && sessions.isCurrent(record, tokenEpoch(token));
|
|
16078
|
+
}
|
|
16079
|
+
/**
|
|
16080
|
+
* #2233: `/login` for a dashboard requester. The same controller and checks; a CLI that is not installed is refused
|
|
16081
|
+
* with its install command (chat's install-first runs a terminal of its own, which the dashboard does not offer).
|
|
16082
|
+
*/
|
|
16083
|
+
startWebDashboardLogin(backendArg, chat, opts) {
|
|
16084
|
+
const wanted = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
|
|
16085
|
+
if (isRemovedBackend(wanted))
|
|
16086
|
+
return Promise.resolve(removedBackendMessage(wanted));
|
|
16087
|
+
const info = BACKEND_INSTALLATION_INFO[wanted];
|
|
16088
|
+
if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !this.isCliInstalled(wanted)) {
|
|
16089
|
+
return Promise.resolve(t("login.web_dashboard_not_installed", wanted, info.install));
|
|
16090
|
+
}
|
|
16091
|
+
if (!opts.skipAuthCheck)
|
|
16092
|
+
this.recordLoginFlow(wanted, "login", chat.userId);
|
|
16093
|
+
return this.webLogin.start(backendArg, chat, opts);
|
|
16094
|
+
}
|
|
15079
16095
|
/**
|
|
15080
16096
|
* Post the backend chooser for a bare `/login`. Caller enforces admin.
|
|
15081
16097
|
* Returns why the chooser could not be posted, for the caller to tell the
|
|
@@ -15718,6 +16734,36 @@ export class FleetManager {
|
|
|
15718
16734
|
const failure = await this.promptLoginBackends(chat);
|
|
15719
16735
|
await data.respond(failure ?? t("login.chooser_posted"));
|
|
15720
16736
|
}
|
|
16737
|
+
/** #2293: whose cross-instance messages search leaves out now (web-api /ui/search). */
|
|
16738
|
+
searchVisibility() { return xmsgVisibility(this.fleetConfig); }
|
|
16739
|
+
/** #2293: a cross-instance message for the search index (outbound-handlers, after it was admitted). */
|
|
16740
|
+
indexCrossInstance(record) {
|
|
16741
|
+
this.searchIndex?.record(xmsgDoc(record));
|
|
16742
|
+
}
|
|
16743
|
+
/**
|
|
16744
|
+
* #2293: the global search index, at fleet start — after the web chat is restored (the backfill reads it). Its worker
|
|
16745
|
+
* owns search.db; see search-index.ts. Off with web.search.enabled: false.
|
|
16746
|
+
*/
|
|
16747
|
+
startSearchIndex(fleet) {
|
|
16748
|
+
const config = fleet.web?.search;
|
|
16749
|
+
if (config?.enabled === false || this.searchIndex)
|
|
16750
|
+
return;
|
|
16751
|
+
const history = this.webChatHistory;
|
|
16752
|
+
this.searchIndex = new SearchIndex({
|
|
16753
|
+
path: join(this.dataDir, "search.db"),
|
|
16754
|
+
retentionDays: config?.retention_days ?? SEARCH_RETENTION_DAYS,
|
|
16755
|
+
outboxPath: join(this.dataDir, "delivery-outbox.db"),
|
|
16756
|
+
schedulerPath: join(this.dataDir, "scheduler.db"),
|
|
16757
|
+
chatDocs: function* () { for (const name of history.instances())
|
|
16758
|
+
for (const m of history.list(name)) {
|
|
16759
|
+
const doc = chatDoc(m);
|
|
16760
|
+
if (doc)
|
|
16761
|
+
yield doc;
|
|
16762
|
+
} },
|
|
16763
|
+
log: (level, msg) => this.logger[level](msg),
|
|
16764
|
+
});
|
|
16765
|
+
this.searchIndex.start();
|
|
16766
|
+
}
|
|
15721
16767
|
queueMirrorMessage(text) {
|
|
15722
16768
|
const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
|
|
15723
16769
|
if (mirrorTopicId == null || !this.adapter)
|
|
@@ -15812,8 +16858,15 @@ export class FleetManager {
|
|
|
15812
16858
|
links: m.links,
|
|
15813
16859
|
channel: m.channel,
|
|
15814
16860
|
from: m.from,
|
|
16861
|
+
replyTo: m.replyTo,
|
|
16862
|
+
cards: m.cards,
|
|
16863
|
+
posts: m.posts, // #2281: kept beside the row, never broadcast (not a row field)
|
|
16864
|
+
reserved: m.reserved, // #1774: an agent reply's place, taken when it was sent
|
|
15815
16865
|
});
|
|
15816
|
-
|
|
16866
|
+
// A sending page's own id for its pending bubble rides on the live copy only: never recorded, never replayed.
|
|
16867
|
+
const clientId = typeof data.clientId === "string" ? data.clientId : "";
|
|
16868
|
+
broadcastSseEvent(this.sseClients, event, clientId ? { ...recorded, clientId } : recorded, onError, this.webChatHistory.cursorOf(recorded));
|
|
16869
|
+
this.searchIndex?.record(chatDoc(recorded));
|
|
15817
16870
|
// #1629: live-only receipt after the recorded agent reply. Never persist/replay it or give it an SSE cursor.
|
|
15818
16871
|
if (recorded.role === "agent")
|
|
15819
16872
|
broadcastSseEvent(this.sseClients, "agent_reply_live", { v: 1, boot: recorded.boot, id: recorded.id, instance: recorded.instance }, onError);
|
|
@@ -15826,6 +16879,53 @@ export class FleetManager {
|
|
|
15826
16879
|
}
|
|
15827
16880
|
broadcastSseEvent(this.sseClients, event, data, onError);
|
|
15828
16881
|
}
|
|
16882
|
+
/**
|
|
16883
|
+
* The task board's live side: every committed change goes to the open pages (never to a guest) — the board's new
|
|
16884
|
+
* revision and the card numbers it touched; not who touched them (the page does not need it, and a public-link page
|
|
16885
|
+
* must not learn a hidden instance's name from it). Its news goes to the instances whose cards someone else changed:
|
|
16886
|
+
* one "[Task board]" notice per instance, gathered for 10 s, held by the delivery outbox until it is taken
|
|
16887
|
+
* (task-board-waker.ts).
|
|
16888
|
+
*/
|
|
16889
|
+
attachTaskBoard(db) {
|
|
16890
|
+
this.taskBoardWaker?.stop();
|
|
16891
|
+
// #2303: each instance's cards at once, read at every claim (a fleet.yaml edit applies to the next one).
|
|
16892
|
+
db.board.setCardLimit((name) => boardCardLimit(this.fleetConfig, name));
|
|
16893
|
+
const waker = this.taskBoardWaker = new TaskBoardWaker({
|
|
16894
|
+
deliver: (instance, message, key) => this.admitTaskBoardNotice(instance, message, key),
|
|
16895
|
+
isInstance: (name) => this.fleetConfig?.instances?.[name] != null,
|
|
16896
|
+
holds: (wake) => db.board.wakeHolds(wake),
|
|
16897
|
+
card: (uuid) => db.board.findCard(uuid),
|
|
16898
|
+
// #2352: the card's Needs you buttons are in this instance's own channel (it is the card's lane).
|
|
16899
|
+
buttonsHere: (instance, uuid) => {
|
|
16900
|
+
// #2355 review: no live message, no buttons — the old words then.
|
|
16901
|
+
if (this.fleetConfig?.needs_you?.live_message === false)
|
|
16902
|
+
return false;
|
|
16903
|
+
const card = db.board.findCard(uuid);
|
|
16904
|
+
// #2434: only while it waits for the person — a card that does not is in no Needs you, so no buttons anywhere.
|
|
16905
|
+
if (!card || !waitsForPerson(card, this.dispatcherGone))
|
|
16906
|
+
return false;
|
|
16907
|
+
const leads = new Map(db.board.listProjects(true).map((p) => [p.id, p.leadInstance]));
|
|
16908
|
+
return this.needsYouLane(card, leads) === instance;
|
|
16909
|
+
},
|
|
16910
|
+
log: (level, message, extra) => this.logger[level](extra ?? {}, message),
|
|
16911
|
+
});
|
|
16912
|
+
db.board.onChange(({ rev, ids, projects, wakes }) => {
|
|
16913
|
+
this.emitSseEvent("tasks", { rev, ids, projects });
|
|
16914
|
+
waker.note(wakes);
|
|
16915
|
+
// step 3: cards waiting for the person are Needs you items; #2355 review: a project's lead routes them too.
|
|
16916
|
+
if (ids.length || projects)
|
|
16917
|
+
this.needsYou?.poke();
|
|
16918
|
+
});
|
|
16919
|
+
}
|
|
16920
|
+
/** A task board notice, as AgEnD's own (not a person's words): through the delivery outbox, held until delivered. */
|
|
16921
|
+
admitTaskBoardNotice(instance, message, key) {
|
|
16922
|
+
this.ensureDeliveryOutbox();
|
|
16923
|
+
const outbox = this.deliveryOutbox;
|
|
16924
|
+
if (!outbox)
|
|
16925
|
+
return false;
|
|
16926
|
+
outbox.admitSystemNotice(instance, key, message, new Date());
|
|
16927
|
+
return true;
|
|
16928
|
+
}
|
|
15829
16929
|
listClaimedTasks(assignee) {
|
|
15830
16930
|
try {
|
|
15831
16931
|
return this.scheduler?.db.listTasks({ assignee, status: "claimed" }) ?? [];
|
|
@@ -16028,6 +17128,27 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16028
17128
|
this.syncRoleSkills(workDir, backend, "general", knowledgeDir);
|
|
16029
17129
|
this.logger.debug({ knowledgeDir, workDir, backend }, "Synced general knowledge files");
|
|
16030
17130
|
}
|
|
17131
|
+
/** Update only AgEnD-managed skill copies; no launch or restart is needed for an experimental switch. */
|
|
17132
|
+
canvasWorkerSettingChanged() {
|
|
17133
|
+
this.refreshExperimentalSkills();
|
|
17134
|
+
this.emitSseEvent("status", this.getUiStatus());
|
|
17135
|
+
}
|
|
17136
|
+
refreshExperimentalSkills() {
|
|
17137
|
+
for (const [name, daemon] of this.daemons) {
|
|
17138
|
+
try {
|
|
17139
|
+
const cfg = daemon.getConfigSnapshot();
|
|
17140
|
+
const backend = cfg.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
17141
|
+
const workDir = this.resolveKnowledgeWorkDir(cfg.working_directory, backend, name);
|
|
17142
|
+
const classic = this.classicChannels?.getAll().some(ch => ch.instanceName === name) === true;
|
|
17143
|
+
if (classic && !insideManagedWorkspaces(workDir))
|
|
17144
|
+
continue;
|
|
17145
|
+
this.syncRoleSkills(workDir, backend, classic ? "classic" : cfg.general_topic ? "general" : "worker");
|
|
17146
|
+
}
|
|
17147
|
+
catch (err) {
|
|
17148
|
+
this.logger.warn({ name, err }, "Could not refresh experimental skill visibility");
|
|
17149
|
+
}
|
|
17150
|
+
}
|
|
17151
|
+
}
|
|
16031
17152
|
/** Publish only the bundled skills eligible for an instance role. */
|
|
16032
17153
|
syncRoleSkills(workDir, backend, role, knowledgeDir) {
|
|
16033
17154
|
const skillSegments = FleetManager.SKILLS_DIR_SEGMENTS[backend];
|
|
@@ -16041,7 +17162,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16041
17162
|
// can keep those copies current; other backends never had unmanaged
|
|
16042
17163
|
// AgEnD-published skills and must retain the normal user-ownership guard.
|
|
16043
17164
|
const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
|
|
16044
|
-
this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged
|
|
17165
|
+
this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged,
|
|
17166
|
+
// #2163: a Classic bot's skills folder (or a skill in it) may be a link out of its workspace; every path is
|
|
17167
|
+
// checked where it really lands, just before it is written.
|
|
17168
|
+
role === "classic" ? (path) => insideManagedWorkspaces(path) : undefined);
|
|
16045
17169
|
this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
|
|
16046
17170
|
}
|
|
16047
17171
|
/**
|
|
@@ -16127,11 +17251,63 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16127
17251
|
* migration exception is the pre-manifest Kiro General layout explicitly
|
|
16128
17252
|
* selected by adoptLegacyUnmanaged.
|
|
16129
17253
|
*/
|
|
16130
|
-
|
|
17254
|
+
/**
|
|
17255
|
+
* #2482: the user's own skills that shadow bundled ones, said once per directory and change — not once per name on
|
|
17256
|
+
* every start (11 lines a start on the host). The record (`skipped-skills.json` in the AgEnD home) keeps what was said
|
|
17257
|
+
* across restarts; a new name or an edited SKILL.md is said again.
|
|
17258
|
+
*/
|
|
17259
|
+
reportSkippedSkills(destSkills, names) {
|
|
17260
|
+
const signature = names.map(name => {
|
|
17261
|
+
let content = "";
|
|
17262
|
+
try {
|
|
17263
|
+
content = readFileSync(join(destSkills, name, "SKILL.md"), "utf-8");
|
|
17264
|
+
}
|
|
17265
|
+
catch { /* gone: its name alone */ }
|
|
17266
|
+
return `${name}:${createHash("sha256").update(content).digest("hex").slice(0, 16)}`;
|
|
17267
|
+
}).join(",");
|
|
17268
|
+
if (this.skippedSkillsSaid.get(destSkills) === signature)
|
|
17269
|
+
return;
|
|
17270
|
+
this.skippedSkillsSaid.set(destSkills, signature);
|
|
17271
|
+
const path = join(this.dataDir, "skipped-skills.json");
|
|
17272
|
+
// Only a log's memory: whatever is in the file (missing, corrupt, any other shape) never stands in the sync's way
|
|
17273
|
+
// (#2493 review) — a record that cannot be read is no record, and the warning is said.
|
|
17274
|
+
const record = Object.create(null);
|
|
17275
|
+
try {
|
|
17276
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
17277
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
17278
|
+
for (const [key, value] of Object.entries(parsed))
|
|
17279
|
+
if (typeof value === "string")
|
|
17280
|
+
record[key] = value;
|
|
17281
|
+
}
|
|
17282
|
+
}
|
|
17283
|
+
catch { /* none yet, or unreadable */ }
|
|
17284
|
+
if (Object.hasOwn(record, destSkills) && record[destSkills] === signature) {
|
|
17285
|
+
this.logger.debug({ skills: names, destSkills }, "Skipping bundled skills — unchanged since it was last said");
|
|
17286
|
+
return;
|
|
17287
|
+
}
|
|
17288
|
+
record[destSkills] = signature;
|
|
17289
|
+
try {
|
|
17290
|
+
writeFileSync(path, JSON.stringify(record) + "\n");
|
|
17291
|
+
}
|
|
17292
|
+
catch { /* said this time; said again next start */ }
|
|
17293
|
+
this.logger.warn({ skills: names, destSkills }, "Skipping bundled skills — skills of these names exist but were not published by AgEnD");
|
|
17294
|
+
}
|
|
17295
|
+
syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false, confined) {
|
|
16131
17296
|
if (!existsSync(srcSkills))
|
|
16132
17297
|
return;
|
|
17298
|
+
const refuse = (path) => {
|
|
17299
|
+
if (!confined || confined(path))
|
|
17300
|
+
return false;
|
|
17301
|
+
this.logger.warn({ path, destSkills }, "Not publishing skills — the path leads out of the managed workspace");
|
|
17302
|
+
return true;
|
|
17303
|
+
};
|
|
17304
|
+
if (refuse(destSkills))
|
|
17305
|
+
return;
|
|
16133
17306
|
mkdirSync(destSkills, { recursive: true });
|
|
16134
17307
|
const manifestPath = join(destSkills, ".agend-managed-skills.json");
|
|
17308
|
+
// Its ownership claims decide what is replaced or removed below: one that lives elsewhere is never believed.
|
|
17309
|
+
if (refuse(manifestPath))
|
|
17310
|
+
return;
|
|
16135
17311
|
const hadManifest = existsSync(manifestPath);
|
|
16136
17312
|
let previouslyManaged = [];
|
|
16137
17313
|
try {
|
|
@@ -16144,41 +17320,42 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16144
17320
|
.filter(name => existsSync(join(srcSkills, name, "SKILL.md")))
|
|
16145
17321
|
.sort();
|
|
16146
17322
|
// Pre-manifest Kiro General copied bundled skills directly into
|
|
16147
|
-
// .kiro/skills. If every existing skill is still
|
|
16148
|
-
// the
|
|
16149
|
-
// the ownership manifest. Any extra skill name keeps the directory fully
|
|
17323
|
+
// .kiro/skills. If every existing skill is still bundled AND belongs to
|
|
17324
|
+
// the closed pre-manifest cohort, adopt it once, update it below, and
|
|
17325
|
+
// write the ownership manifest. Any extra skill name keeps the directory fully
|
|
16150
17326
|
// on the user-owned/collision path.
|
|
16151
17327
|
if (!hadManifest && adoptLegacyUnmanaged) {
|
|
16152
17328
|
const existing = readdirSync(destSkills, { withFileTypes: true })
|
|
16153
17329
|
.filter(entry => entry.isDirectory() && existsSync(join(destSkills, entry.name, "SKILL.md")))
|
|
16154
17330
|
.map(entry => entry.name)
|
|
16155
17331
|
.sort();
|
|
16156
|
-
if (existing.length > 0 && existing.every(name => bundled.includes(name))) {
|
|
17332
|
+
if (existing.length > 0 && existing.every(name => bundled.includes(name) && PRE_MANIFEST_KIRO_SKILLS.has(name))) {
|
|
16157
17333
|
previouslyManaged = existing;
|
|
16158
17334
|
this.logger.info({ skills: existing, destSkills }, "Adopted legacy AgEnD skills into managed ownership");
|
|
16159
17335
|
}
|
|
16160
17336
|
}
|
|
16161
17337
|
const eligible = bundled.filter(name => {
|
|
16162
17338
|
const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
|
|
16163
|
-
// General is the coordinator and receives
|
|
16164
|
-
//
|
|
16165
|
-
return role === "general" || roles.includes(
|
|
17339
|
+
// General is the coordinator and receives every playbook. Workers and Classic bots receive only skills
|
|
17340
|
+
// explicitly marked for their role (#2163).
|
|
17341
|
+
return role === "general" || roles.includes(role);
|
|
16166
17342
|
});
|
|
16167
17343
|
const managed = [];
|
|
17344
|
+
const skipped = [];
|
|
16168
17345
|
for (const name of eligible) {
|
|
16169
17346
|
const destDir = join(destSkills, name);
|
|
16170
17347
|
const dest = join(destDir, "SKILL.md");
|
|
16171
17348
|
const isOurs = previouslyManaged.includes(name) || !existsSync(destDir);
|
|
16172
17349
|
if (!isOurs) {
|
|
16173
|
-
// The user's skill still wins on every sync; only the warning is deduped
|
|
16174
|
-
|
|
16175
|
-
|
|
16176
|
-
this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
|
|
16177
|
-
}
|
|
17350
|
+
// The user's skill still wins on every sync; only the warning is deduped (#2482: one line per directory, and
|
|
17351
|
+
// only when what is skipped there changed since it was last said — across restarts too).
|
|
17352
|
+
skipped.push(name);
|
|
16178
17353
|
continue;
|
|
16179
17354
|
}
|
|
16180
17355
|
managed.push(name);
|
|
16181
|
-
const newContent =
|
|
17356
|
+
const newContent = bundledSkillContents(join(srcSkills, name), this.canvasWorkerEnabled());
|
|
17357
|
+
if (refuse(dest))
|
|
17358
|
+
continue;
|
|
16182
17359
|
try {
|
|
16183
17360
|
if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
|
|
16184
17361
|
continue;
|
|
@@ -16187,6 +17364,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16187
17364
|
mkdirSync(destDir, { recursive: true });
|
|
16188
17365
|
writeFileSync(dest, newContent);
|
|
16189
17366
|
}
|
|
17367
|
+
if (skipped.length)
|
|
17368
|
+
this.reportSkippedSkills(destSkills, skipped);
|
|
16190
17369
|
// Remove managed skills that are no longer bundled OR no longer eligible
|
|
16191
17370
|
// for this role. This makes a shared → general-only metadata change take
|
|
16192
17371
|
// effect on the next worker startup instead of leaving stale capability.
|
|
@@ -16225,9 +17404,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16225
17404
|
if (!Array.isArray(frontmatter.roles))
|
|
16226
17405
|
throw new Error("roles must be an array");
|
|
16227
17406
|
const roles = [...new Set(frontmatter.roles)]
|
|
16228
|
-
.filter((value) => value === "general" || value === "worker");
|
|
17407
|
+
.filter((value) => value === "general" || value === "worker" || value === "classic");
|
|
16229
17408
|
if (roles.length !== frontmatter.roles.length || roles.length === 0) {
|
|
16230
|
-
throw new Error("roles must contain only general and/or
|
|
17409
|
+
throw new Error("roles must contain only general, worker and/or classic");
|
|
16231
17410
|
}
|
|
16232
17411
|
return roles;
|
|
16233
17412
|
}
|
|
@@ -16518,7 +17697,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16518
17697
|
try {
|
|
16519
17698
|
const tmpPath = await dlAdapter.downloadAttachment(att.fileId);
|
|
16520
17699
|
const inboxDir = join(getAgendHome(), "workspaces", instanceName, "inbox");
|
|
16521
|
-
mkdirSync(inboxDir, { recursive: true });
|
|
17700
|
+
mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
|
|
16522
17701
|
const dest = join(inboxDir, basename(tmpPath));
|
|
16523
17702
|
// Copy to destination — failure means this attachment is skipped
|
|
16524
17703
|
try {
|
|
@@ -16930,7 +18109,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
16930
18109
|
}
|
|
16931
18110
|
}
|
|
16932
18111
|
const path = this.cliEnvPath(backend, profile);
|
|
16933
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
18112
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
16934
18113
|
writeFileSync(path, JSON.stringify(env, null, 2));
|
|
16935
18114
|
return env;
|
|
16936
18115
|
}
|
|
@@ -18616,23 +19795,56 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18616
19795
|
* settles, so a later genuine stop (after a restart) still does the work.
|
|
18617
19796
|
*/
|
|
18618
19797
|
stopAll() {
|
|
18619
|
-
this.stopAllInFlight ??= this.doStopAll().finally(() => {
|
|
19798
|
+
this.stopAllInFlight ??= this.doStopAll().finally(() => {
|
|
19799
|
+
// #1956: after the stop's own tmux work; anything later goes direct. A later startAll starts it again.
|
|
19800
|
+
if (this.tmuxExecHelperTimer) {
|
|
19801
|
+
clearInterval(this.tmuxExecHelperTimer);
|
|
19802
|
+
this.tmuxExecHelperTimer = null;
|
|
19803
|
+
}
|
|
19804
|
+
// #2364: and the stop waits (bounded) for the helper to leave on its own, before the caller exits the process.
|
|
19805
|
+
return tmuxExecHelper.stop();
|
|
19806
|
+
}).finally(() => { this.stopAllInFlight = null; });
|
|
18620
19807
|
return this.stopAllInFlight;
|
|
18621
19808
|
}
|
|
19809
|
+
tmuxExecHelperTimer = null;
|
|
18622
19810
|
stopAllInFlight = null;
|
|
19811
|
+
/** Stops release memory. Reassess between batches without waiting for recovery. */
|
|
19812
|
+
async stopInstancesInBatches(entries, stop) {
|
|
19813
|
+
let index = 0;
|
|
19814
|
+
while (index < entries.length) {
|
|
19815
|
+
// Linux procfs can still be read after the periodic sampler is stopped.
|
|
19816
|
+
// Do not revive a stopped Darwin probe/worker just to size a quit batch;
|
|
19817
|
+
// its full shutdown uses the conservative batch size instead.
|
|
19818
|
+
const level = this.memoryPressure.platform === "darwin"
|
|
19819
|
+
? this.shuttingDown ? "elevated" : this.memoryPressure.snapshot().level
|
|
19820
|
+
: this.memoryPressure.sample().level;
|
|
19821
|
+
const size = fleetStopConcurrency(entries.length, level);
|
|
19822
|
+
const batch = entries.slice(index, index + size);
|
|
19823
|
+
index += batch.length;
|
|
19824
|
+
await Promise.allSettled(batch.map(stop));
|
|
19825
|
+
}
|
|
19826
|
+
}
|
|
18623
19827
|
async doStopAll() {
|
|
19828
|
+
this.startupMemoryDeadline?.stop();
|
|
19829
|
+
this.startupMemoryDeadline = null;
|
|
18624
19830
|
this.startupComplete = false;
|
|
19831
|
+
this.startupDashboard = null;
|
|
18625
19832
|
this.reloadPending = false;
|
|
18626
19833
|
// Before anything is stopped: everything that dies from here on dies
|
|
18627
19834
|
// because we asked it to. Set synchronously — doStopAll runs to its first
|
|
18628
19835
|
// await in the same tick as the signal handler, so no event can slip in.
|
|
18629
19836
|
this.shuttingDown = true;
|
|
19837
|
+
if (this.updateProgressTimer) {
|
|
19838
|
+
clearInterval(this.updateProgressTimer);
|
|
19839
|
+
this.updateProgressTimer = null;
|
|
19840
|
+
}
|
|
18630
19841
|
this.settingsGeneration++;
|
|
18631
19842
|
this.settingsConfirmation?.store.close();
|
|
18632
19843
|
this.settingsConfirmation = null;
|
|
18633
19844
|
const settingsControl = this.settingsControl;
|
|
18634
19845
|
this.settingsControl = null;
|
|
18635
19846
|
const settingsControlStopped = settingsControl?.close();
|
|
19847
|
+
this.publicLinkAutoOpen?.stop();
|
|
18636
19848
|
const publicStopped = this.publicWebLink?.close("fleet shutdown");
|
|
18637
19849
|
// #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
|
|
18638
19850
|
// adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
|
|
@@ -18690,6 +19902,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18690
19902
|
this.clearStatuslineWatchers();
|
|
18691
19903
|
this.costGuard?.stop();
|
|
18692
19904
|
this.dailySummary?.stop();
|
|
19905
|
+
this.taskBoardSummary?.stop();
|
|
18693
19906
|
this.dailyTipScheduler?.stop();
|
|
18694
19907
|
this.dailyTipScheduler = null;
|
|
18695
19908
|
if (this.updateCheckTimer) {
|
|
@@ -18770,27 +19983,29 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
18770
19983
|
// this is the last moment the prompts can be collapsed.
|
|
18771
19984
|
await this.retirePendingNoncePrompts();
|
|
18772
19985
|
this.topicArchiver.stop();
|
|
19986
|
+
this.taskBoardWaker?.stop();
|
|
19987
|
+
this.appIconStore?.close();
|
|
18773
19988
|
this.scheduler?.shutdown();
|
|
19989
|
+
// What it was sent is written first (bounded: search-index.ts close()).
|
|
19990
|
+
const search = this.searchIndex;
|
|
19991
|
+
this.searchIndex = null;
|
|
19992
|
+
await search?.close();
|
|
18774
19993
|
// Stop instances in parallel batches to avoid long sequential waits.
|
|
18775
19994
|
// Concurrency scales with fleet size — larger fleets tolerate more parallel
|
|
18776
19995
|
// tmux ops, while small fleets stay conservative to avoid overwhelming the
|
|
18777
19996
|
// tmux server.
|
|
18778
19997
|
const entries = [...this.daemons.entries()];
|
|
18779
|
-
const STOP_CONCURRENCY = entries.length > 30 ? 15 : entries.length >= 10 ? 10 : 5;
|
|
18780
19998
|
for (const [name] of entries)
|
|
18781
19999
|
this.ipcStoppingInstances.add(name);
|
|
18782
|
-
|
|
18783
|
-
|
|
18784
|
-
|
|
18785
|
-
|
|
18786
|
-
|
|
18787
|
-
}
|
|
18788
|
-
|
|
18789
|
-
|
|
18790
|
-
|
|
18791
|
-
this.daemons.delete(name);
|
|
18792
|
-
}));
|
|
18793
|
-
}
|
|
20000
|
+
await this.stopInstancesInBatches(entries, async ([name, daemon]) => {
|
|
20001
|
+
try {
|
|
20002
|
+
await daemon.stop();
|
|
20003
|
+
}
|
|
20004
|
+
catch (err) {
|
|
20005
|
+
this.logger.warn({ name, err }, "Stop failed");
|
|
20006
|
+
}
|
|
20007
|
+
this.daemons.delete(name);
|
|
20008
|
+
});
|
|
18794
20009
|
// Close IPC clients in parallel — serial close over a large fleet adds
|
|
18795
20010
|
// noticeable latency.
|
|
18796
20011
|
await Promise.all([...this.instanceIpcClients.values()].map(ipc => Promise.resolve(ipc.close()).catch(() => { })));
|
|
@@ -19001,6 +20216,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19001
20216
|
this.rawFleetConfig = previousRawConfig;
|
|
19002
20217
|
this.rawFleetDocument = previousRawDocument;
|
|
19003
20218
|
this.savedFleetConfigSnapshot = previousSavedSnapshot;
|
|
20219
|
+
this.observeWebAuthority(); // #2453 review: the configuration back in force is the one seen
|
|
19004
20220
|
this.logger.error({
|
|
19005
20221
|
oldCount,
|
|
19006
20222
|
newCount,
|
|
@@ -19020,6 +20236,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19020
20236
|
// mark every row done and tell the user "changes applied".
|
|
19021
20237
|
return { rejected: why };
|
|
19022
20238
|
}
|
|
20239
|
+
if (canvasWorkerEnabled(oldConfig?.web) !== this.canvasWorkerEnabled()) {
|
|
20240
|
+
this.canvasWorkerSettingChanged();
|
|
20241
|
+
}
|
|
19023
20242
|
// Classic behavior settings share the fleet defaults but are not entries
|
|
19024
20243
|
// in fleet.yaml. Snapshot the old effective chain before reloading the
|
|
19025
20244
|
// Classic file so SIGHUP can hot-apply either source without waiting for
|
|
@@ -19096,8 +20315,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19096
20315
|
// so an old/new diff alone can miss the live daemon's stale value.
|
|
19097
20316
|
for (const [name, config] of Object.entries(newInstances)) {
|
|
19098
20317
|
if (!this.daemons.has(name)) {
|
|
19099
|
-
// New instance — startInstance already calls connectIpcToInstance
|
|
19100
|
-
|
|
20318
|
+
// New instance — startInstance already calls connectIpcToInstance. #2482: a paused one is not started (startInstance
|
|
20319
|
+
// returns at its pause); say so, quietly, instead of "starting" on every reload.
|
|
20320
|
+
if (this.lifecycle.isPaused(name))
|
|
20321
|
+
this.logger.debug({ name }, "Paused instance in config — not started");
|
|
20322
|
+
else
|
|
20323
|
+
this.logger.info({ name }, "New instance in config — starting");
|
|
19101
20324
|
observe?.(name, "restart", "running");
|
|
19102
20325
|
await this.startInstanceUnattended(name, config, topicMode, "new instance");
|
|
19103
20326
|
observe?.(name, "restart", "done");
|
|
@@ -19106,6 +20329,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19106
20329
|
const daemon = this.daemons.get(name);
|
|
19107
20330
|
const runtimeConfig = daemon.getConfigSnapshot?.() ?? oldConfig.instances[name];
|
|
19108
20331
|
const change = classifyInstanceChange(runtimeConfig, config);
|
|
20332
|
+
// (b) The person switched its task board on: tell it (its tool list shows the `task` tool at once).
|
|
20333
|
+
if (taskBoardSwitchedOn(runtimeConfig.dashboard, config.dashboard)) {
|
|
20334
|
+
this.admitTaskBoardNotice(name, TASK_BOARD_ON_NOTICE, `task-board-on:${name}:${Date.now()}`);
|
|
20335
|
+
}
|
|
19109
20336
|
if (change === "restart") {
|
|
19110
20337
|
this.logger.info({ name }, "Instance config changed — restarting");
|
|
19111
20338
|
observe?.(name, "restart", "running");
|
|
@@ -19269,6 +20496,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19269
20496
|
return false;
|
|
19270
20497
|
}
|
|
19271
20498
|
/** Non-null when startup found the running config and fleet.yaml disagreeing. */
|
|
20499
|
+
/**
|
|
20500
|
+
* #2346 (settings schema): whether a connection added now would start at once — what canHotAddConnections would decide
|
|
20501
|
+
* for the current config with one more connection appended: another one is running, and nothing else this process took
|
|
20502
|
+
* at startup has changed since (a pending locale or access change, say, needs AgEnD restarted anyway).
|
|
20503
|
+
*/
|
|
20504
|
+
connectionsStartLive() {
|
|
20505
|
+
if (!this.fleetConfig)
|
|
20506
|
+
return false;
|
|
20507
|
+
const current = this.fleetConfig.channels ?? (this.fleetConfig.channel ? [this.fleetConfig.channel] : []);
|
|
20508
|
+
const probe = { id: "\u0000probe", type: "telegram", mode: "topic", bot_token_env: "\u0000PROBE" };
|
|
20509
|
+
const next = { ...this.fleetConfig, channels: [...current, probe] };
|
|
20510
|
+
delete next.channel;
|
|
20511
|
+
return this.canHotAddConnections(next);
|
|
20512
|
+
}
|
|
19272
20513
|
fleetSignatureMismatchKeys() {
|
|
19273
20514
|
return this.fleetSignatureMismatch;
|
|
19274
20515
|
}
|
|
@@ -19487,12 +20728,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19487
20728
|
return this.fleetConfig?.web?.provider_secrets === true;
|
|
19488
20729
|
}
|
|
19489
20730
|
listProviderSecrets() {
|
|
19490
|
-
return PROVIDER_SECRET_SPECS.map(spec => ({
|
|
20731
|
+
return [...PROVIDER_SECRET_SPECS, PUBLIC_TUNNEL_SECRET_SPEC].map(spec => ({
|
|
19491
20732
|
id: spec.id,
|
|
19492
20733
|
display_name: spec.displayName,
|
|
19493
20734
|
kind: spec.kind,
|
|
19494
20735
|
token_present: !!process.env[spec.envKey],
|
|
19495
|
-
verifier: spec.verifier ? "available" : "unsupported",
|
|
20736
|
+
verifier: spec.kind === "tunnel_token" ? "local_format" : spec.verifier ? "available" : "unsupported",
|
|
19496
20737
|
activation: spec.activation,
|
|
19497
20738
|
stale_consumers: this.providerSecretStaleConsumers(spec.envKey),
|
|
19498
20739
|
}));
|
|
@@ -19502,7 +20743,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19502
20743
|
// a child process's private environment safely, so report the conservative
|
|
19503
20744
|
// set of already-running children; the UI can then say "restart these"
|
|
19504
20745
|
// rather than claiming an existing process reloaded.
|
|
19505
|
-
if (envKey === "GROQ_API_KEY")
|
|
20746
|
+
if (envKey === "GROQ_API_KEY" || envKey === PUBLIC_TUNNEL_TOKEN_ENV)
|
|
19506
20747
|
return [];
|
|
19507
20748
|
return [...this.children.keys()].sort();
|
|
19508
20749
|
}
|
|
@@ -19520,10 +20761,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19520
20761
|
if (!spec || !this.providerSecretEnvAllowed(spec.envKey)) {
|
|
19521
20762
|
return { ok: false, status: "invalid", error: "provider secret is not configured" };
|
|
19522
20763
|
}
|
|
19523
|
-
|
|
20764
|
+
const tunnel = spec.kind === "tunnel_token";
|
|
20765
|
+
if (!tunnel && !spec.verifier)
|
|
19524
20766
|
return { ok: false, status: "unsupported_verifier", error: "this provider has no supported verifier" };
|
|
19525
|
-
|
|
19526
|
-
|
|
20767
|
+
const secret = tunnel && !input.clear ? normalizePublicTunnelToken(input.secret) : input.secret;
|
|
20768
|
+
if (input.clear && (!tunnel || input.secret !== "")
|
|
20769
|
+
|| !input.clear && (!secret || secret.length > 4096 || /[\r\n\0]/.test(secret) || /[^\x20-\x7e]/.test(secret))) {
|
|
20770
|
+
return { ok: false, status: "invalid", error: tunnel ? PUBLIC_TUNNEL_TOKEN_HINT : "secret is invalid" };
|
|
19527
20771
|
}
|
|
19528
20772
|
const challengeKey = `${input.sessionBinding}:api_key:${spec.id}:${spec.envKey}:${input.idempotencyKey}`;
|
|
19529
20773
|
const existingId = this.providerSecretChallengesByKey.get(challengeKey);
|
|
@@ -19533,7 +20777,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19533
20777
|
}
|
|
19534
20778
|
if (existingId)
|
|
19535
20779
|
this.providerSecretChallengesByKey.delete(challengeKey);
|
|
19536
|
-
const result = await verifyProviderSecret(spec, input.secret, this.providerSecretHttpClient);
|
|
20780
|
+
const result = tunnel ? { ok: true } : await verifyProviderSecret(spec, input.secret, this.providerSecretHttpClient);
|
|
19537
20781
|
if (!result.ok) {
|
|
19538
20782
|
// Do not log provider detail: the HTTP verifier already redacted it and
|
|
19539
20783
|
// this endpoint has no need to disclose whether a key was close to valid.
|
|
@@ -19551,7 +20795,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19551
20795
|
operation: "provider-secret.apply",
|
|
19552
20796
|
idempotencyKey: input.idempotencyKey,
|
|
19553
20797
|
expiresAt, deadline: performance.now() + SECRET_CHALLENGE_TTL_MS,
|
|
19554
|
-
secret:
|
|
20798
|
+
secret: secret,
|
|
19555
20799
|
};
|
|
19556
20800
|
this.providerSecretChallenges.set(challenge.id, challenge);
|
|
19557
20801
|
this.providerSecretChallengesByKey.set(challengeKey, challenge.id);
|
|
@@ -19621,6 +20865,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19621
20865
|
async runProviderSecretApply(job, secret, execution) {
|
|
19622
20866
|
const spec = providerSecretSpec(job.specId);
|
|
19623
20867
|
const allowed = new Set([
|
|
20868
|
+
PUBLIC_TUNNEL_TOKEN_ENV,
|
|
19624
20869
|
...PROVIDER_SECRET_SPECS.map(item => item.envKey),
|
|
19625
20870
|
...(this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).map(channel => channel.bot_token_env),
|
|
19626
20871
|
]);
|
|
@@ -19634,7 +20879,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19634
20879
|
// Construct inside the transaction: symlink/permission refusal must
|
|
19635
20880
|
// settle the job as a safe failure, not escape the queued microtask.
|
|
19636
20881
|
store = new SecretStore(join(this.dataDir, ".env"), allowed, { owner: execution?.owner });
|
|
19637
|
-
const write = () => {
|
|
20882
|
+
const write = () => {
|
|
20883
|
+
before = spec.kind === "tunnel_token" && !secret ? store.clear(job.envKey) : store.write(job.envKey, secret);
|
|
20884
|
+
wrote = true;
|
|
20885
|
+
if (secret)
|
|
20886
|
+
process.env[job.envKey] = secret;
|
|
20887
|
+
else
|
|
20888
|
+
delete process.env[job.envKey];
|
|
20889
|
+
};
|
|
19638
20890
|
if (execution)
|
|
19639
20891
|
execution.mutate(write);
|
|
19640
20892
|
else
|
|
@@ -19656,12 +20908,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19656
20908
|
job.finishedAt = Date.now();
|
|
19657
20909
|
}
|
|
19658
20910
|
catch (err) {
|
|
19659
|
-
const safe = safeSecretError(err, secret);
|
|
20911
|
+
const safe = spec?.kind === "tunnel_token" ? "connector token change failed" : safeSecretError(err, secret);
|
|
19660
20912
|
this.logger.warn({ specId: job.specId, reason: safe }, "Provider API-key apply failed");
|
|
19661
20913
|
try {
|
|
19662
20914
|
// Memory and disk have separate receipts: a host-edited file must
|
|
19663
20915
|
// remain, but cannot strand our owned process value or hook snapshot.
|
|
19664
|
-
if (wrote && process.env[job.envKey] === secret) {
|
|
20916
|
+
if (wrote && (process.env[job.envKey] ?? "") === secret) {
|
|
19665
20917
|
if (previousProcessValue === undefined)
|
|
19666
20918
|
delete process.env[job.envKey];
|
|
19667
20919
|
else
|
|
@@ -19701,6 +20953,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
19701
20953
|
* hot activation contract explicit and gives tests a failure seam; no generic
|
|
19702
20954
|
* SIGHUP or caller-provided hook is ever executed. */
|
|
19703
20955
|
async runProviderSecretReloadHook(hookId, _next, _previous) {
|
|
20956
|
+
if (hookId === "cloudflare.public_link") {
|
|
20957
|
+
await this.publicWebLink?.close("connector token changed");
|
|
20958
|
+
return;
|
|
20959
|
+
}
|
|
19704
20960
|
if (hookId !== "groq.voice")
|
|
19705
20961
|
throw new Error("unknown provider secret reload hook");
|
|
19706
20962
|
const before = this.providerSecretHotSnapshots.get(hookId);
|
|
@@ -20373,7 +21629,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20373
21629
|
all = false;
|
|
20374
21630
|
const message = err?.message ?? String(err);
|
|
20375
21631
|
this.adapterState.set(id, { status: "retrying", retryCount: 0, lastError: message });
|
|
20376
|
-
this.scheduleAdapterRetry(id
|
|
21632
|
+
this.scheduleAdapterRetry(id);
|
|
20377
21633
|
}
|
|
20378
21634
|
}
|
|
20379
21635
|
this.classicChannels?.configureAdapters(this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []));
|
|
@@ -20550,7 +21806,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20550
21806
|
await ipc.close();
|
|
20551
21807
|
}
|
|
20552
21808
|
this.instanceIpcClients.clear();
|
|
20553
|
-
await
|
|
21809
|
+
await this.stopInstancesInBatches(instanceNames, name => this.stopInstance(name));
|
|
20554
21810
|
// Kill remaining orphan windows to prevent stale state on restart
|
|
20555
21811
|
try {
|
|
20556
21812
|
const agendNames = new Set(instanceNames);
|
|
@@ -20683,7 +21939,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20683
21939
|
// up to 15s each, and on a beta build BOTH ran — 30s with no WATCHDOG ping,
|
|
20684
21940
|
// past WatchdogSec's half-interval and enough for systemd to SIGABRT the fleet
|
|
20685
21941
|
// for a background version check.
|
|
20686
|
-
const { execFile } = await import("
|
|
21942
|
+
const { execFile } = await import("./child-process.js");
|
|
20687
21943
|
const { promisify } = await import("node:util");
|
|
20688
21944
|
const execFileP = promisify(execFile);
|
|
20689
21945
|
const npmVersion = async (spec) => {
|
|
@@ -20871,6 +22127,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20871
22127
|
if (this.guestScope(owner).size)
|
|
20872
22128
|
return "ok";
|
|
20873
22129
|
if (owner && this.fleetConfig?.web?.guests?.enabled === true) {
|
|
22130
|
+
// Before adapters initialize, topic scope is unknown, not revoked. Keep the returning cookie but grant no
|
|
22131
|
+
// request until its configured world exists. Removed connections and initialized denied access stay final.
|
|
22132
|
+
const channels = this.fleetConfig.channels ?? (this.fleetConfig.channel ? [this.fleetConfig.channel] : []);
|
|
22133
|
+
if (this.startupDashboard && !this.worlds.has(owner.adapterId)
|
|
22134
|
+
&& channels.some(channel => (channel.id ?? channel.type) === owner.adapterId))
|
|
22135
|
+
return "pending";
|
|
20874
22136
|
const members = this.guestRooms(), rooms = guestGroupRooms(this.classicChannels?.getAll() ?? [], owner);
|
|
20875
22137
|
if (rooms.some((room) => members.pending(room, owner.userId))) {
|
|
20876
22138
|
void this.refreshGuestRooms(owner, { mode: "triggered" }); // rate-limited per room (30 s), single-flight
|
|
@@ -20969,16 +22231,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
20969
22231
|
res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
|
|
20970
22232
|
return;
|
|
20971
22233
|
}
|
|
20972
|
-
if (closing) {
|
|
22234
|
+
if (closing || this.shuttingDown) {
|
|
20973
22235
|
res.writeHead(503, { Connection: "close", "Retry-After": "10" });
|
|
20974
22236
|
res.end(JSON.stringify({ error: "fleet stopping" }));
|
|
20975
22237
|
return;
|
|
20976
22238
|
}
|
|
20977
22239
|
const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
|
|
22240
|
+
if (!this.canvasWorkerEnabled() && isCanvasWorkerHttpPath(requestPath)) {
|
|
22241
|
+
res.writeHead(403, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
22242
|
+
res.end(JSON.stringify({ error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE }));
|
|
22243
|
+
return;
|
|
22244
|
+
}
|
|
20978
22245
|
if (this.effectBootstrap.handle(req, res))
|
|
20979
22246
|
return;
|
|
20980
22247
|
// Branding is public, after Host admission: automatic favicon requests
|
|
20981
22248
|
// must not become noisy web-token 401s in the browser console.
|
|
22249
|
+
// #2308: the icon routes and the pages' icon links follow the app icon set in Settings.
|
|
22250
|
+
useAppIconStore(this.appIcon);
|
|
20982
22251
|
if (handleWebIconRequest(req, res, requestPath))
|
|
20983
22252
|
return;
|
|
20984
22253
|
// Public: the health probe, /agent (instance-token auth of its own), the
|
|
@@ -21010,6 +22279,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21010
22279
|
// #1920: a guest reaches only GUEST_ROUTES, within the scope derived for it now — default deny, before any
|
|
21011
22280
|
// handler, on this listener and the public link's gateway alike (both come through here).
|
|
21012
22281
|
if (decision.via === "session" && decision.session.tier === "guest") {
|
|
22282
|
+
// A browser opening a page gets a page in its language, not the JSON an API caller gets (#2399 smoke).
|
|
22283
|
+
const navigating = req.method === "GET" && String(req.headers.accept ?? "").includes("text/html") && isWebPageNavigation(requestPath);
|
|
21013
22284
|
const owner = decision.session.owner;
|
|
21014
22285
|
const admitted = decision.session;
|
|
21015
22286
|
const admission = this.guestAdmission(admitted);
|
|
@@ -21020,12 +22291,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21020
22291
|
return;
|
|
21021
22292
|
}
|
|
21022
22293
|
if (admission === "gone") {
|
|
22294
|
+
if (navigating) {
|
|
22295
|
+
serveGuestRefusedPage(res, 401, "ended");
|
|
22296
|
+
return;
|
|
22297
|
+
}
|
|
21023
22298
|
res.writeHead(401);
|
|
21024
22299
|
res.end(JSON.stringify({ error: "guest_scope_empty" }));
|
|
21025
22300
|
return;
|
|
21026
22301
|
}
|
|
21027
22302
|
const verdict = guestRouteDecision(req.method ?? "GET", parsedUrl, this.guestScope(owner));
|
|
21028
22303
|
if (verdict.kind === "deny") {
|
|
22304
|
+
if (navigating) {
|
|
22305
|
+
serveGuestRefusedPage(res, verdict.status, "members");
|
|
22306
|
+
return;
|
|
22307
|
+
}
|
|
21029
22308
|
res.writeHead(verdict.status);
|
|
21030
22309
|
res.end(JSON.stringify({ error: verdict.error }));
|
|
21031
22310
|
return;
|
|
@@ -21041,6 +22320,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21041
22320
|
// #1920 §5.1: guests chat unless the host made them read-only (web.guests.chat: false).
|
|
21042
22321
|
chat: () => this.fleetConfig?.web?.guests?.chat !== false });
|
|
21043
22322
|
}
|
|
22323
|
+
if (decision.via === "session" && this.webToken) {
|
|
22324
|
+
const upgrade = sessionCookieUpgrade(req, this.webToken, decision.session);
|
|
22325
|
+
// No cookie header is set before this gate; auth routes own their headers separately.
|
|
22326
|
+
if (upgrade)
|
|
22327
|
+
res.setHeader("Set-Cookie", upgrade);
|
|
22328
|
+
}
|
|
21044
22329
|
}
|
|
21045
22330
|
if (req.method === "GET" && req.url === "/health") {
|
|
21046
22331
|
const health = this.getFleetHealth();
|
|
@@ -21052,6 +22337,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21052
22337
|
res.end(JSON.stringify(health));
|
|
21053
22338
|
return;
|
|
21054
22339
|
}
|
|
22340
|
+
// Threat: lifecycle/config/chat writes racing boot-owned starts and reconciliation. Reads stay live;
|
|
22341
|
+
// authentication keeps its own checks so a browser can sign in or sign out while agents start.
|
|
22342
|
+
if (this.startupDashboard && !["GET", "HEAD", "OPTIONS"].includes(req.method ?? "GET")
|
|
22343
|
+
&& !requestPath.startsWith("/auth/")) {
|
|
22344
|
+
res.writeHead(503, { "Retry-After": "2", "Cache-Control": "no-store", Connection: "close" });
|
|
22345
|
+
res.end(JSON.stringify({ code: "fleet_starting", error: t("fleet.starting_retry") }));
|
|
22346
|
+
return;
|
|
22347
|
+
}
|
|
21055
22348
|
if (req.method === "GET" && req.url === "/status") {
|
|
21056
22349
|
const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => {
|
|
21057
22350
|
const statusFile = join(this.getInstanceDir(name), "statusline.json");
|
|
@@ -21322,6 +22615,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21322
22615
|
}
|
|
21323
22616
|
}
|
|
21324
22617
|
startHealthServer(port) {
|
|
22618
|
+
let settled;
|
|
22619
|
+
const listening = new Promise(resolve => { settled = resolve; });
|
|
21325
22620
|
this.startedAt = Date.now();
|
|
21326
22621
|
this.healthServerListening = false;
|
|
21327
22622
|
this.healthPortRetried = false;
|
|
@@ -21336,6 +22631,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21336
22631
|
const drain = trackWebServer(server);
|
|
21337
22632
|
this.healthServer = server;
|
|
21338
22633
|
this.healthServerDrain = drain;
|
|
22634
|
+
server.once("close", settled);
|
|
21339
22635
|
const markListening = (afterTakeover = false) => {
|
|
21340
22636
|
this.healthServerListening = true;
|
|
21341
22637
|
this.logger.info({ port }, afterTakeover
|
|
@@ -21349,6 +22645,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21349
22645
|
// #1306: the preview listener starts once the web listener is bound — its frame-ancestors name the real port.
|
|
21350
22646
|
const bound = this.healthServer?.address();
|
|
21351
22647
|
this.startPreviewListener(port, bound && typeof bound === "object" ? bound.port : port);
|
|
22648
|
+
settled();
|
|
21352
22649
|
};
|
|
21353
22650
|
this.healthServer.on("error", (err) => {
|
|
21354
22651
|
this.healthServerListening = false;
|
|
@@ -21356,6 +22653,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21356
22653
|
if (this.healthPortRetried) {
|
|
21357
22654
|
this.logger.error({ err, port }, "Health port still in use after takeover — dashboard disabled");
|
|
21358
22655
|
this.notifyFleetError(t("dashboard.port_in_use", port));
|
|
22656
|
+
settled();
|
|
21359
22657
|
return;
|
|
21360
22658
|
}
|
|
21361
22659
|
this.healthPortRetried = true;
|
|
@@ -21391,16 +22689,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21391
22689
|
this.logger.debug({ err }, "Old fleet process kill skipped (already gone or no permission)");
|
|
21392
22690
|
}
|
|
21393
22691
|
setTimeout(() => {
|
|
21394
|
-
if (
|
|
22692
|
+
if (this.healthServer !== server || this.shuttingDown) {
|
|
22693
|
+
settled();
|
|
21395
22694
|
return;
|
|
21396
|
-
|
|
22695
|
+
}
|
|
22696
|
+
server.listen(port, "127.0.0.1", () => markListening(true));
|
|
21397
22697
|
}, 1500);
|
|
21398
22698
|
return;
|
|
21399
22699
|
}
|
|
21400
22700
|
this.logger.error({ err, port }, "Health server error");
|
|
21401
22701
|
this.notifyFleetError(t("dashboard.server_failed", err.message));
|
|
22702
|
+
settled();
|
|
21402
22703
|
});
|
|
21403
22704
|
this.healthServer.listen(port, "127.0.0.1", () => markListening());
|
|
22705
|
+
return listening;
|
|
21404
22706
|
}
|
|
21405
22707
|
/**
|
|
21406
22708
|
* #1306: the preview listener, beside the web listener and closed with it. When it cannot listen (the port is
|
|
@@ -21422,6 +22724,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21422
22724
|
return;
|
|
21423
22725
|
}
|
|
21424
22726
|
const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig,
|
|
22727
|
+
canvasWorkerEnabled: () => this.canvasWorkerEnabled(),
|
|
21425
22728
|
// #1723: with a web.preview_origin, the open public link's dashboard frames this listener's preview too.
|
|
21426
22729
|
extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; } });
|
|
21427
22730
|
this.previewListener = listener;
|
|
@@ -21544,15 +22847,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21544
22847
|
this.logger.debug({ err }, "bots unreadable (getUiStatus)");
|
|
21545
22848
|
}
|
|
21546
22849
|
const instances = names.map(name => {
|
|
21547
|
-
|
|
21548
|
-
|
|
21549
|
-
|
|
21550
|
-
const data = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
21551
|
-
cost = data.cost?.total_cost_usd ?? 0;
|
|
21552
|
-
}
|
|
21553
|
-
catch (err) {
|
|
21554
|
-
this.logger.debug({ err, name }, "statusline.json read failed (getUiStatus)");
|
|
21555
|
-
}
|
|
22850
|
+
// #1956: read once for this instance; its cost, context and model come from it.
|
|
22851
|
+
const statusline = readStatuslineJson(this.dataDir, name);
|
|
22852
|
+
const cost = statusline?.cost?.total_cost_usd ?? 0;
|
|
21556
22853
|
// Align with /ctx: statusline for claude-code, pane scrape for kiro/grok/codex.
|
|
21557
22854
|
const classic = classicOnly.includes(name);
|
|
21558
22855
|
const backend = classic
|
|
@@ -21560,14 +22857,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21560
22857
|
: (this.fleetConfig?.instances[name]?.backend
|
|
21561
22858
|
?? this.fleetConfig?.defaults?.backend
|
|
21562
22859
|
?? "claude-code");
|
|
21563
|
-
const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name) });
|
|
22860
|
+
const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name), statusline });
|
|
21564
22861
|
// context_pct: null when unavailable, not 0
|
|
21565
22862
|
const context_pct = context ?? null;
|
|
21566
22863
|
// Model: Only Claude Code has live statusline; others use the effective resolver.
|
|
21567
|
-
//
|
|
22864
|
+
// statuslineModel gives the /ctx-aligned display_name+id combo for Claude (readStatuslineModel's rule).
|
|
21568
22865
|
// Non-Claude backends must NOT read statusline.json model (may be stale from previous Claude run).
|
|
21569
22866
|
const resolved = this.resolveInstanceModel(name);
|
|
21570
|
-
const liveModel = backend === "claude-code" ?
|
|
22867
|
+
const liveModel = backend === "claude-code" ? statuslineModel(statusline) : null;
|
|
21571
22868
|
// Display value: live model for Claude, resolved.display for others (includes "auto (default)" for Kiro)
|
|
21572
22869
|
const model = liveModel ?? resolved.display;
|
|
21573
22870
|
// model_source: "live" when Claude statusline succeeded, else the resolver's source
|
|
@@ -21605,7 +22902,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21605
22902
|
return {};
|
|
21606
22903
|
} })(),
|
|
21607
22904
|
// #2118: only this Chat's placement content; guest projection drops global revisions.
|
|
21608
|
-
effect_chat_v: createHash("sha256").update(JSON.stringify(this.themeStore.get(name).placements.effects)).digest("hex").slice(0, 32),
|
|
22905
|
+
effect_chat_v: this.canvasWorkerEnabled() ? createHash("sha256").update(JSON.stringify(this.themeStore.get(name).placements.effects)).digest("hex").slice(0, 32) : null,
|
|
21609
22906
|
// #1958: the identity colour's tiers (the page resolves them: the agent's only while it shows agents' themes).
|
|
21610
22907
|
color: { ...(colors.get(name)?.color ? { person: colors.get(name).color } : {}), ...(image.agentColor ? { agent: image.agentColor } : {}),
|
|
21611
22908
|
...(image.roleColor ? { role: image.roleColor } : {}), auto: autoIdentityColor(name) },
|
|
@@ -21614,6 +22911,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21614
22911
|
description: identity.description || undefined,
|
|
21615
22912
|
role: identity.role || undefined,
|
|
21616
22913
|
tags: identity.tags,
|
|
22914
|
+
teams: instanceTeams(this.fleetConfig?.teams, name), // #2172: the sidebar groups a team's members under it
|
|
22915
|
+
last_reply: this.webChatHistory.lastAgentReply(name) ?? undefined, // #2179: its last reply to a person (ISO)
|
|
21617
22916
|
status: this.getInstanceStatus(name),
|
|
21618
22917
|
// `state` (presentation: may be awaiting_input) and `execution_state` (working / idle / stuck, or null —
|
|
21619
22918
|
// what the dashboard's activity events carry) come from instancePresentation (#1212).
|
|
@@ -21630,7 +22929,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
21630
22929
|
};
|
|
21631
22930
|
});
|
|
21632
22931
|
return {
|
|
22932
|
+
canvas_worker: this.canvasWorkerEnabled(),
|
|
21633
22933
|
instances,
|
|
22934
|
+
startup: this.startupDashboard?.snapshot() ?? null,
|
|
21634
22935
|
publicLink: this.getPublicWebStatus(),
|
|
21635
22936
|
uptime: Math.floor((Date.now() - this.startedAt) / 1000),
|
|
21636
22937
|
// #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.
|