@gajae-code/coding-agent 0.12.0 → 0.12.2
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/CHANGELOG.md +111 -0
- package/dist/types/async/job-manager.d.ts +17 -3
- package/dist/types/config/settings-schema.d.ts +56 -2
- package/dist/types/exa/mcp-client.d.ts +10 -1
- package/dist/types/exec/bash-executor.d.ts +45 -12
- package/dist/types/extensibility/extensions/types.d.ts +16 -2
- package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
- package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
- package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
- package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/ralplan-review-conflicts.d.ts +114 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +23 -3
- package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
- package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +37 -80
- package/dist/types/modes/acp/acp-agent.d.ts +2 -19
- package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
- package/dist/types/modes/components/irc-sidebar.d.ts +16 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
- package/dist/types/modes/components/settings-selector.d.ts +9 -0
- package/dist/types/modes/components/status-line/gh.d.ts +5 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
- package/dist/types/modes/controllers/command-controller.d.ts +1 -1
- package/dist/types/modes/controllers/event-controller.d.ts +1 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +9 -0
- package/dist/types/modes/theme/theme.d.ts +3 -1
- package/dist/types/modes/types.d.ts +32 -0
- package/dist/types/reminders/star-reminder.d.ts +3 -13
- package/dist/types/runtime-mcp/manager.d.ts +1 -0
- package/dist/types/sdk/acp/adapter.d.ts +3 -1
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -1
- package/dist/types/sdk/bus/config.d.ts +6 -2
- package/dist/types/sdk/bus/index.d.ts +29 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
- package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
- package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
- package/dist/types/sdk/bus/rich-render.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
- package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
- package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
- package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
- package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
- package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
- package/dist/types/sdk/prompt-status.d.ts +23 -3
- package/dist/types/sdk/session.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +40 -2
- package/dist/types/session/artifacts.d.ts +8 -0
- package/dist/types/session/blob-store.d.ts +57 -4
- package/dist/types/session/internal/managed-session-storage.d.ts +30 -4
- package/dist/types/session/session-manager.d.ts +23 -6
- package/dist/types/session/session-storage.d.ts +4 -0
- package/dist/types/session/streaming-output.d.ts +36 -7
- package/dist/types/setup/credential-import.d.ts +12 -0
- package/dist/types/skill-state/active-state.d.ts +12 -0
- package/dist/types/skill-state/workflow-hud.d.ts +6 -0
- package/dist/types/task/executor.d.ts +3 -2
- package/dist/types/task/index.d.ts +7 -1
- package/dist/types/task/output-manager.d.ts +1 -0
- package/dist/types/task/provider-retry-status.d.ts +8 -0
- package/dist/types/task/receipt.d.ts +9 -1
- package/dist/types/task/types.d.ts +23 -0
- package/dist/types/tools/ask-answer-registry.d.ts +2 -2
- package/dist/types/tools/bash-interactive.d.ts +4 -1
- package/dist/types/tools/bash.d.ts +3 -1
- package/dist/types/tools/image-gen.d.ts +4 -0
- package/dist/types/tools/index.d.ts +6 -0
- package/dist/types/tools/output-meta.d.ts +23 -2
- package/dist/types/tools/path-utils.d.ts +1 -0
- package/dist/types/tools/read.d.ts +0 -6
- package/dist/types/tools/review.d.ts +5 -7
- package/dist/types/tools/subagent.d.ts +2 -0
- package/dist/types/utils/gh.d.ts +13 -0
- package/dist/types/utils/image-resize.d.ts +1 -0
- package/dist/types/web/search/providers/kimi.d.ts +2 -0
- package/dist/types/web/search/providers/searxng.d.ts +7 -0
- package/dist/types/web/search/providers/xai.d.ts +2 -0
- package/package.json +10 -8
- package/scripts/acp-conformance-agent.ts +218 -0
- package/scripts/capture-gjc-bundle-settings.ts +306 -0
- package/scripts/dogfood-ralplan-review-conflicts.ts +272 -0
- package/scripts/generate-sdk-operation-inventory.ts +5 -0
- package/scripts/resident-memory-bench.ts +1341 -0
- package/scripts/run-acp-conformance.ts +290 -0
- package/src/async/job-manager.ts +99 -39
- package/src/cli/fast-help.ts +1 -1
- package/src/cli/plugin-cli.ts +176 -18
- package/src/commands/team.ts +19 -1
- package/src/config/file-lock.ts +11 -2
- package/src/config/settings-schema.ts +53 -2
- package/src/coordinator-mcp/question-gate-codec.ts +1 -1
- package/src/coordinator-mcp/server.ts +5 -12
- package/src/debug/index.ts +42 -17
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
- package/src/defaults/gjc/skills/ralplan/SKILL.md +25 -20
- package/src/defaults/gjc/skills/team/SKILL.md +2 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +39 -26
- package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
- package/src/defaults/gjc-defaults.ts +4 -4
- package/src/exa/mcp-client.ts +12 -3
- package/src/exec/bash-executor.ts +144 -27
- package/src/extensibility/extensions/runner.ts +15 -0
- package/src/extensibility/extensions/types.ts +18 -1
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
- package/src/extensibility/gjc-plugins/index.ts +19 -2
- package/src/extensibility/gjc-plugins/installer.ts +189 -50
- package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
- package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
- package/src/extensibility/gjc-plugins/observability.ts +3 -3
- package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
- package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
- package/src/extensibility/gjc-plugins/schema.ts +84 -12
- package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
- package/src/extensibility/gjc-plugins/types.ts +141 -0
- package/src/extensibility/gjc-plugins/validation.ts +9 -0
- package/src/gjc-runtime/launch-tmux.ts +25 -6
- package/src/gjc-runtime/launch-worktree.ts +103 -1
- package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
- package/src/gjc-runtime/managed-owner-admission.ts +1 -1
- package/src/gjc-runtime/ralplan-review-conflicts.ts +472 -0
- package/src/gjc-runtime/ralplan-runtime.ts +491 -27
- package/src/gjc-runtime/team-launch.ts +8 -0
- package/src/gjc-runtime/team-runtime.ts +140 -32
- package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
- package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
- package/src/gjc-runtime/tmux-sessions.ts +58 -13
- package/src/gjc-runtime/ultragoal-evidence.ts +6 -74
- package/src/gjc-runtime/ultragoal-runtime.ts +635 -612
- package/src/gjc-runtime/workflow-manifest.generated.json +37 -99
- package/src/gjc-runtime/workflow-manifest.ts +21 -70
- package/src/internal-urls/docs-index.generated.ts +16 -16
- package/src/modes/DESIGN.md +104 -0
- package/src/modes/acp/acp-agent.ts +298 -186
- package/src/modes/components/gjc-bundle-settings.ts +508 -0
- package/src/modes/components/irc-sidebar.ts +30 -1
- package/src/modes/components/notifications-settings-editor.ts +12 -1
- package/src/modes/components/oauth-selector.ts +68 -30
- package/src/modes/components/session-observer-overlay.ts +305 -86
- package/src/modes/components/settings-selector.ts +56 -5
- package/src/modes/components/status-line/gh.ts +16 -0
- package/src/modes/components/tool-status-header.ts +3 -15
- package/src/modes/components/transcript-viewer-overlay.ts +99 -24
- package/src/modes/controllers/command-controller.ts +42 -34
- package/src/modes/controllers/event-controller.ts +27 -16
- package/src/modes/controllers/extension-ui-controller.ts +193 -34
- package/src/modes/controllers/selector-controller.ts +160 -22
- package/src/modes/interactive-mode.ts +189 -62
- package/src/modes/jobs-observer.ts +1 -1
- package/src/modes/runtime-init.ts +3 -0
- package/src/modes/theme/theme.ts +11 -9
- package/src/modes/types.ts +88 -0
- package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
- package/src/prompts/system/eager-todo.md +8 -4
- package/src/prompts/system/system-prompt.md +11 -4
- package/src/prompts/tools/bash.md +5 -5
- package/src/prompts/tools/search-tool-bm25.md +5 -0
- package/src/prompts/tools/task.md +1 -1
- package/src/reminders/star-reminder.ts +4 -42
- package/src/runtime-mcp/manager.ts +19 -4
- package/src/sdk/acp/adapter.ts +22 -6
- package/src/sdk/broker/lifecycle.ts +18 -13
- package/src/sdk/broker/transport.ts +35 -3
- package/src/sdk/bus/attachment-registry.ts +5 -1
- package/src/sdk/bus/chat-daemon-cli.ts +4 -39
- package/src/sdk/bus/chat-daemon-control.ts +7 -3
- package/src/sdk/bus/config.ts +18 -2
- package/src/sdk/bus/index.ts +661 -126
- package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
- package/src/sdk/bus/lifecycle-commands.ts +4 -1
- package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
- package/src/sdk/bus/reconciliation-store.ts +95 -16
- package/src/sdk/bus/rich-draft.ts +1 -1
- package/src/sdk/bus/rich-render.ts +2 -2
- package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
- package/src/sdk/bus/telegram-cli.ts +33 -8
- package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +12 -4
- package/src/sdk/bus/telegram-daemon.ts +916 -23
- package/src/sdk/bus/telegram-reference.ts +40 -3
- package/src/sdk/bus/threaded-render.ts +48 -3
- package/src/sdk/bus/topic-registry.ts +20 -0
- package/src/sdk/host/query/revision-store.ts +36 -2
- package/src/sdk/host/reverse-leases.ts +44 -7
- package/src/sdk/prompt-status.ts +16 -3
- package/src/sdk/protocol/operation-inventory.generated.json +33 -0
- package/src/sdk/session.ts +89 -9
- package/src/session/agent-session.ts +844 -173
- package/src/session/artifacts.ts +22 -0
- package/src/session/auth-broker-config.ts +10 -3
- package/src/session/blob-store.ts +915 -24
- package/src/session/internal/managed-session-scope.ts +200 -111
- package/src/session/internal/managed-session-storage.ts +492 -62
- package/src/session/session-manager.ts +1614 -435
- package/src/session/session-storage.ts +38 -45
- package/src/session/streaming-output.ts +238 -68
- package/src/setup/credential-import.ts +75 -13
- package/src/skill-state/active-state.ts +32 -0
- package/src/skill-state/workflow-hud.ts +17 -0
- package/src/skill-state/workflow-mutation-guard.ts +496 -2
- package/src/task/executor.ts +122 -39
- package/src/task/index.ts +280 -45
- package/src/task/output-manager.ts +24 -14
- package/src/task/provider-retry-status.ts +16 -0
- package/src/task/receipt.ts +34 -8
- package/src/task/render.ts +6 -2
- package/src/task/types.ts +108 -0
- package/src/tools/ask-answer-registry.ts +12 -6
- package/src/tools/ask.ts +86 -55
- package/src/tools/ast-edit.ts +1 -0
- package/src/tools/ast-grep.ts +1 -0
- package/src/tools/bash-interactive.ts +8 -3
- package/src/tools/bash.ts +503 -60
- package/src/tools/find.ts +1 -0
- package/src/tools/image-gen.ts +29 -5
- package/src/tools/index.ts +6 -0
- package/src/tools/output-meta.ts +108 -14
- package/src/tools/path-utils.ts +3 -2
- package/src/tools/read.ts +18 -0
- package/src/tools/review.ts +5 -7
- package/src/tools/search.ts +1 -0
- package/src/tools/subagent-render.ts +6 -2
- package/src/tools/subagent.ts +6 -0
- package/src/tools/telegram-send.ts +6 -3
- package/src/utils/gh.ts +39 -0
- package/src/utils/image-resize.ts +3 -0
- package/src/web/search/providers/anthropic.ts +4 -4
- package/src/web/search/providers/kimi.ts +15 -5
- package/src/web/search/providers/searxng.ts +47 -4
- package/src/web/search/providers/xai.ts +7 -2
- package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
- package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
- package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable, non-routable sidecar store for "adopt a user-created Telegram forum
|
|
3
|
+
* topic into a preallocated session" intents.
|
|
4
|
+
*
|
|
5
|
+
* When a user creates a new forum topic, the daemon first records durable
|
|
6
|
+
* pending-topic authorization. After folder selection it preallocates an
|
|
7
|
+
* `intendedSessionId` and records an adoption intent here *before* spawning.
|
|
8
|
+
* Later, the authenticated `ensureTopic` create callback reads that intent and
|
|
9
|
+
* returns the user's existing topicId to `TopicRegistry.getOrCreateTopic`, which
|
|
10
|
+
* is the only authority that commits a topic binding. This keeps the intent out
|
|
11
|
+
* of routing/endpoint authority until commit, so a failed spawn or a late socket
|
|
12
|
+
* never deletes the user's topic and never strands a duplicate.
|
|
13
|
+
*
|
|
14
|
+
* Invariants (see `pending-approval.md`):
|
|
15
|
+
* - One file per intent, named `<intendedSessionId>.adoption-intent.json` under
|
|
16
|
+
* the daemon notifications dir. Sweep touches files only; never the Telegram API.
|
|
17
|
+
* - Only `{ intendedSessionId, topicId, chatId, target, createdAt, expiresAt }`
|
|
18
|
+
* is persisted. Bot/control tokens and endpoint digests are never stored.
|
|
19
|
+
* - Owner-only dir (0o700) and 0o600 files; atomic temp→fsync→chmod→rename→parent sync.
|
|
20
|
+
* - `tryClaim` is a synchronous, in-memory claim keyed by topicId; it excludes a
|
|
21
|
+
* second `intendedSessionId` from winning the same topic across awaits. It
|
|
22
|
+
* does not delete the sidecar; `releaseClaim` re-enables retry on commit failure.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { randomUUID } from "node:crypto";
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
import { daemonPaths } from "./daemon-paths";
|
|
29
|
+
import type { SessionCreateTarget } from "./index";
|
|
30
|
+
|
|
31
|
+
/** Persisted document version. */
|
|
32
|
+
export const TELEGRAM_ADOPTION_INTENT_VERSION = 1;
|
|
33
|
+
export type TelegramAdoptionTarget = Extract<SessionCreateTarget, { kind: "existing_path" }>;
|
|
34
|
+
/** Default intent TTL (configurable at write time). Plan starts at 10 minutes. */
|
|
35
|
+
export const DEFAULT_ADOPTION_INTENT_TTL_MS = 10 * 60 * 1000;
|
|
36
|
+
/** Per-intent filename prefix/suffix under the notifications dir. */
|
|
37
|
+
const INTENT_FILE_SUFFIX = ".adoption-intent.json";
|
|
38
|
+
const PENDING_TOPIC_FILE_SUFFIX = ".pending-topic.json";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Minimal async filesystem surface the store needs. Structurally a subset of the
|
|
42
|
+
* daemon's `TelegramDaemonFs` / `ConversationStoreFs`, so the daemon can pass its
|
|
43
|
+
* own `fs` straight through and tests can inject a fake. `open`/`sync`/`close`
|
|
44
|
+
* mirror `ConversationStoreFileHandle` so the atomic fsync path reuses the same
|
|
45
|
+
* shape as the conversation store.
|
|
46
|
+
*/
|
|
47
|
+
export interface AdoptionIntentFs {
|
|
48
|
+
mkdir(directory: string, options: { recursive: true; mode: number }): Promise<unknown>;
|
|
49
|
+
chmod(target: string, mode: number): Promise<void>;
|
|
50
|
+
readFile(file: string, encoding: "utf8"): Promise<string>;
|
|
51
|
+
writeFile(file: string, data: string, options: { mode: number }): Promise<void>;
|
|
52
|
+
rename(from: string, to: string): Promise<void>;
|
|
53
|
+
unlink(file: string): Promise<unknown>;
|
|
54
|
+
readdir(directory: string): Promise<readonly string[]>;
|
|
55
|
+
open(file: string, flags: string): Promise<AdoptionIntentFileHandle>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AdoptionIntentFileHandle {
|
|
59
|
+
sync(): Promise<void>;
|
|
60
|
+
close(): Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const nodeFs: AdoptionIntentFs = {
|
|
64
|
+
mkdir: (dir, opts) => fs.promises.mkdir(dir, opts),
|
|
65
|
+
chmod: (target, mode) => fs.promises.chmod(target, mode),
|
|
66
|
+
readFile: (file, encoding) => fs.promises.readFile(file, encoding),
|
|
67
|
+
writeFile: (file, data, opts) => fs.promises.writeFile(file, data, opts),
|
|
68
|
+
rename: (from, to) => fs.promises.rename(from, to),
|
|
69
|
+
unlink: file => fs.promises.unlink(file),
|
|
70
|
+
readdir: dir => fs.promises.readdir(dir),
|
|
71
|
+
open: async (file, flags) => fs.promises.open(file, flags),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** What gets persisted per intent. No tokens, no digests. */
|
|
75
|
+
export interface TelegramAdoptionIntent {
|
|
76
|
+
intendedSessionId: string;
|
|
77
|
+
topicId: number;
|
|
78
|
+
chatId: string;
|
|
79
|
+
target: TelegramAdoptionTarget;
|
|
80
|
+
createdAt: number;
|
|
81
|
+
expiresAt: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface PersistedIntent {
|
|
85
|
+
version: typeof TELEGRAM_ADOPTION_INTENT_VERSION;
|
|
86
|
+
intent: TelegramAdoptionIntent;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Durable authorization that a topic was observed through forum_topic_created. */
|
|
90
|
+
export interface TelegramPendingTopic {
|
|
91
|
+
topicId: number;
|
|
92
|
+
chatId: string;
|
|
93
|
+
createdAt: number;
|
|
94
|
+
expiresAt: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface PersistedPendingTopic {
|
|
98
|
+
version: typeof TELEGRAM_ADOPTION_INTENT_VERSION;
|
|
99
|
+
pendingTopic: TelegramPendingTopic;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
103
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isSessionCreateTarget(value: unknown): value is TelegramAdoptionTarget {
|
|
107
|
+
return isRecord(value) && value.kind === "existing_path" && typeof value.path === "string";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isPersistedIntent(value: unknown): value is PersistedIntent {
|
|
111
|
+
if (!isRecord(value)) return false;
|
|
112
|
+
if (value.version !== TELEGRAM_ADOPTION_INTENT_VERSION) return false;
|
|
113
|
+
const intent = value.intent;
|
|
114
|
+
if (!isRecord(intent)) return false;
|
|
115
|
+
return (
|
|
116
|
+
typeof intent.intendedSessionId === "string" &&
|
|
117
|
+
intent.intendedSessionId.length > 0 &&
|
|
118
|
+
typeof intent.topicId === "number" &&
|
|
119
|
+
Number.isSafeInteger(intent.topicId) &&
|
|
120
|
+
intent.topicId > 0 &&
|
|
121
|
+
typeof intent.chatId === "string" &&
|
|
122
|
+
typeof intent.createdAt === "number" &&
|
|
123
|
+
Number.isFinite(intent.createdAt) &&
|
|
124
|
+
typeof intent.expiresAt === "number" &&
|
|
125
|
+
Number.isFinite(intent.expiresAt) &&
|
|
126
|
+
intent.expiresAt > intent.createdAt &&
|
|
127
|
+
isSessionCreateTarget(intent.target)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isPersistedPendingTopic(value: unknown): value is PersistedPendingTopic {
|
|
132
|
+
if (!isRecord(value) || value.version !== TELEGRAM_ADOPTION_INTENT_VERSION) return false;
|
|
133
|
+
const pending = value.pendingTopic;
|
|
134
|
+
return (
|
|
135
|
+
isRecord(pending) &&
|
|
136
|
+
typeof pending.topicId === "number" &&
|
|
137
|
+
Number.isSafeInteger(pending.topicId) &&
|
|
138
|
+
pending.topicId > 0 &&
|
|
139
|
+
typeof pending.chatId === "string" &&
|
|
140
|
+
typeof pending.createdAt === "number" &&
|
|
141
|
+
Number.isFinite(pending.createdAt) &&
|
|
142
|
+
typeof pending.expiresAt === "number" &&
|
|
143
|
+
Number.isFinite(pending.expiresAt) &&
|
|
144
|
+
pending.expiresAt > pending.createdAt
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Compose the per-intent sidecar path. Public so callers/tests can inspect it. */
|
|
149
|
+
export function adoptionIntentFilePath(agentDir: string, intendedSessionId: string): string {
|
|
150
|
+
return path.join(daemonPaths(agentDir).dir, `${intendedSessionId}${INTENT_FILE_SUFFIX}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Compose the sidecar path for a user-created topic awaiting a folder choice. */
|
|
154
|
+
export function pendingTopicFilePath(agentDir: string, topicId: number): string {
|
|
155
|
+
return path.join(daemonPaths(agentDir).dir, `${topicId}${PENDING_TOPIC_FILE_SUFFIX}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Build a fresh intent with `createdAt`/`expiresAt` derived from `now`/`ttlMs`. */
|
|
159
|
+
export function buildAdoptionIntent(input: {
|
|
160
|
+
intendedSessionId: string;
|
|
161
|
+
topicId: number;
|
|
162
|
+
chatId: string;
|
|
163
|
+
target: TelegramAdoptionTarget;
|
|
164
|
+
now?: number;
|
|
165
|
+
ttlMs?: number;
|
|
166
|
+
}): TelegramAdoptionIntent {
|
|
167
|
+
const createdAt = input.now ?? Date.now();
|
|
168
|
+
const ttl = input.ttlMs ?? DEFAULT_ADOPTION_INTENT_TTL_MS;
|
|
169
|
+
return {
|
|
170
|
+
intendedSessionId: input.intendedSessionId,
|
|
171
|
+
topicId: input.topicId,
|
|
172
|
+
chatId: input.chatId,
|
|
173
|
+
target: input.target,
|
|
174
|
+
createdAt,
|
|
175
|
+
expiresAt: createdAt + ttl,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Test-only: filename suffix used to recognize intent sidecars in a directory listing. */
|
|
180
|
+
export const ADOPTION_INTENT_FILENAME_SUFFIX = INTENT_FILE_SUFFIX;
|
|
181
|
+
export const PENDING_TOPIC_FILENAME_SUFFIX = PENDING_TOPIC_FILE_SUFFIX;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Durable, non-routable adoption-intent store. Owns durable sidecar files and
|
|
185
|
+
* the synchronous in-memory topicId claim table. The daemon owns Telegram and
|
|
186
|
+
* lifecycle wiring; this store never calls the Telegram API.
|
|
187
|
+
*/
|
|
188
|
+
export class TelegramAdoptionIntentStore {
|
|
189
|
+
readonly #agentDir: string;
|
|
190
|
+
readonly #dir: string;
|
|
191
|
+
readonly #fsImpl: AdoptionIntentFs;
|
|
192
|
+
readonly #now: () => number;
|
|
193
|
+
readonly #platform: NodeJS.Platform;
|
|
194
|
+
/** intendedSessionId -> intent, rehydrated from disk. */
|
|
195
|
+
readonly #intents = new Map<string, TelegramAdoptionIntent>();
|
|
196
|
+
/** topicId -> intendedSessionId currently holding the synchronous claim. */
|
|
197
|
+
readonly #claims = new Map<number, string>();
|
|
198
|
+
/** topicId -> durable authorization observed from forum_topic_created. */
|
|
199
|
+
readonly #pendingTopics = new Map<number, TelegramPendingTopic>();
|
|
200
|
+
|
|
201
|
+
constructor(input: {
|
|
202
|
+
agentDir: string;
|
|
203
|
+
fs?: AdoptionIntentFs;
|
|
204
|
+
now?: () => number;
|
|
205
|
+
platform?: NodeJS.Platform;
|
|
206
|
+
}) {
|
|
207
|
+
this.#agentDir = input.agentDir;
|
|
208
|
+
this.#dir = daemonPaths(input.agentDir).dir;
|
|
209
|
+
this.#fsImpl = input.fs ?? nodeFs;
|
|
210
|
+
this.#now = input.now ?? Date.now;
|
|
211
|
+
this.#platform = input.platform ?? process.platform;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Directory holding the sidecar files. */
|
|
215
|
+
get directory(): string {
|
|
216
|
+
return this.#dir;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** In-memory lookup by intendedSessionId (no disk read). undefined if absent/expired. */
|
|
220
|
+
bySession(intendedSessionId: string): TelegramAdoptionIntent | undefined {
|
|
221
|
+
const intent = this.#intents.get(intendedSessionId);
|
|
222
|
+
if (intent === undefined) return undefined;
|
|
223
|
+
return this.#now() < intent.expiresAt ? intent : undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** In-memory lookup of the (single) non-expired intent for a topicId. */
|
|
227
|
+
byTopic(topicId: number): TelegramAdoptionIntent | undefined {
|
|
228
|
+
for (const intent of this.#intents.values()) {
|
|
229
|
+
if (intent.topicId !== topicId) continue;
|
|
230
|
+
if (this.#now() >= intent.expiresAt) continue;
|
|
231
|
+
return intent;
|
|
232
|
+
}
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** `true` if a non-expired intent already references `topicId`. */
|
|
237
|
+
hasNonExpiredTopic(topicId: number): boolean {
|
|
238
|
+
return this.byTopic(topicId) !== undefined;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Durable authorization for an observed user-created topic, if still live. */
|
|
242
|
+
pendingTopic(topicId: number): TelegramPendingTopic | undefined {
|
|
243
|
+
const pending = this.#pendingTopics.get(topicId);
|
|
244
|
+
return pending !== undefined && this.#now() < pending.expiresAt ? pending : undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
hasPendingTopic(topicId: number, chatId: string): boolean {
|
|
248
|
+
return this.pendingTopic(topicId)?.chatId === chatId;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Synchronously claim `topicId` for `intendedSessionId`. Returns false if a
|
|
253
|
+
* *different* non-expired intendedSessionId already holds the claim (or has a
|
|
254
|
+
* non-expired intent for the topic). Same intendedSessionId re-claiming its
|
|
255
|
+
* own topic is idempotent and succeeds. The claim is in-memory only; it never
|
|
256
|
+
* deletes the sidecar, so a commit failure can `releaseClaim` and retry.
|
|
257
|
+
*/
|
|
258
|
+
tryClaim(topicId: number, intendedSessionId: string): boolean {
|
|
259
|
+
const holder = this.#claims.get(topicId);
|
|
260
|
+
if (holder !== undefined) return holder === intendedSessionId;
|
|
261
|
+
// No live claim yet: refuse if a *different* non-expired intent already
|
|
262
|
+
// targets this topic (race between two pre-spawn intents on one topic).
|
|
263
|
+
const existing = this.byTopic(topicId);
|
|
264
|
+
if (existing !== undefined && existing.intendedSessionId !== intendedSessionId) return false;
|
|
265
|
+
this.#claims.set(topicId, intendedSessionId);
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Release a claim held by `intendedSessionId` on `topicId`. No-op otherwise. */
|
|
270
|
+
releaseClaim(topicId: number, intendedSessionId: string): void {
|
|
271
|
+
if (this.#claims.get(topicId) === intendedSessionId) this.#claims.delete(topicId);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Durable write of one intent: temp→write→fsync→chmod(0600)→atomic rename→
|
|
276
|
+
* chmod(final, 0600)→parent-dir fsync, then track it in memory. The dir is
|
|
277
|
+
* created owner-only (0700). chmod-ing the final path after the rename makes
|
|
278
|
+
* the 0600 contract durable regardless of whether the platform's rename
|
|
279
|
+
* preserves the source mode (POSIX does; some test/network filesystems and
|
|
280
|
+
* the in-memory test double model rename as copy+delete and would otherwise
|
|
281
|
+
* drop it). The rename is still the atomic commit point; the final chmod only
|
|
282
|
+
* tightens permissions on the already-durable file. The in-memory map is only
|
|
283
|
+
* mutated after the atomic persist succeeds. Rejects (does not swallow) so the
|
|
284
|
+
* daemon can decide fail-closed behavior.
|
|
285
|
+
*/
|
|
286
|
+
async put(intent: TelegramAdoptionIntent): Promise<void> {
|
|
287
|
+
const stored: TelegramAdoptionIntent = {
|
|
288
|
+
intendedSessionId: intent.intendedSessionId,
|
|
289
|
+
topicId: intent.topicId,
|
|
290
|
+
chatId: intent.chatId,
|
|
291
|
+
target: { kind: "existing_path", path: intent.target.path },
|
|
292
|
+
createdAt: intent.createdAt,
|
|
293
|
+
expiresAt: intent.expiresAt,
|
|
294
|
+
};
|
|
295
|
+
const file = adoptionIntentFilePath(this.#agentDir, stored.intendedSessionId);
|
|
296
|
+
const payload: PersistedIntent = { version: TELEGRAM_ADOPTION_INTENT_VERSION, intent: stored };
|
|
297
|
+
if (!isPersistedIntent(payload)) throw new Error("invalid Telegram adoption intent");
|
|
298
|
+
await this.#writeSidecar(file, payload);
|
|
299
|
+
this.#intents.set(stored.intendedSessionId, stored);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Persist authorization for a topic observed through forum_topic_created. */
|
|
303
|
+
async putPendingTopic(pendingTopic: TelegramPendingTopic): Promise<void> {
|
|
304
|
+
const stored: TelegramPendingTopic = {
|
|
305
|
+
topicId: pendingTopic.topicId,
|
|
306
|
+
chatId: pendingTopic.chatId,
|
|
307
|
+
createdAt: pendingTopic.createdAt,
|
|
308
|
+
expiresAt: pendingTopic.expiresAt,
|
|
309
|
+
};
|
|
310
|
+
const file = pendingTopicFilePath(this.#agentDir, stored.topicId);
|
|
311
|
+
const payload: PersistedPendingTopic = {
|
|
312
|
+
version: TELEGRAM_ADOPTION_INTENT_VERSION,
|
|
313
|
+
pendingTopic: stored,
|
|
314
|
+
};
|
|
315
|
+
if (!isPersistedPendingTopic(payload)) throw new Error("invalid Telegram pending topic");
|
|
316
|
+
await this.#writeSidecar(file, payload);
|
|
317
|
+
this.#pendingTopics.set(stored.topicId, stored);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async #writeSidecar(file: string, payload: PersistedIntent | PersistedPendingTopic): Promise<void> {
|
|
321
|
+
await this.#fsImpl.mkdir(this.#dir, { recursive: true, mode: 0o700 });
|
|
322
|
+
await this.#fsImpl.chmod(this.#dir, 0o700);
|
|
323
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
324
|
+
let renamed = false;
|
|
325
|
+
try {
|
|
326
|
+
await this.#fsImpl.writeFile(temporary, `${JSON.stringify(payload)}\n`, { mode: 0o600 });
|
|
327
|
+
await this.#fsImpl.chmod(temporary, 0o600);
|
|
328
|
+
const handle = await this.#fsImpl.open(temporary, "r+");
|
|
329
|
+
try {
|
|
330
|
+
await syncRequired(handle);
|
|
331
|
+
} finally {
|
|
332
|
+
await handle.close();
|
|
333
|
+
}
|
|
334
|
+
await this.#fsImpl.rename(temporary, file);
|
|
335
|
+
renamed = true;
|
|
336
|
+
await this.#fsImpl.chmod(file, 0o600);
|
|
337
|
+
await this.#syncParentDirectory();
|
|
338
|
+
} catch (error) {
|
|
339
|
+
const cleanupTarget = renamed ? file : temporary;
|
|
340
|
+
let cleanupError: unknown;
|
|
341
|
+
try {
|
|
342
|
+
await this.#fsImpl.unlink(cleanupTarget);
|
|
343
|
+
if (renamed) await this.#syncParentDirectory();
|
|
344
|
+
} catch (candidate) {
|
|
345
|
+
if (!isMissing(candidate)) cleanupError = candidate;
|
|
346
|
+
}
|
|
347
|
+
if (cleanupError) throw new AggregateError([error, cleanupError], "Adoption sidecar write and cleanup failed");
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Read a single intent sidecar from disk and track it if it is non-expired
|
|
354
|
+
* and well-formed. Returns the parsed intent (or undefined). Used by
|
|
355
|
+
* `rehydrate`; callers normally use `rehydrate` / `bySession`.
|
|
356
|
+
*/
|
|
357
|
+
async readIntent(intendedSessionId: string): Promise<TelegramAdoptionIntent | undefined> {
|
|
358
|
+
const file = adoptionIntentFilePath(this.#agentDir, intendedSessionId);
|
|
359
|
+
let raw: string;
|
|
360
|
+
try {
|
|
361
|
+
raw = await this.#fsImpl.readFile(file, "utf8");
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (isMissing(error)) return undefined;
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
let parsed: unknown;
|
|
367
|
+
try {
|
|
368
|
+
parsed = JSON.parse(raw);
|
|
369
|
+
} catch {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
if (!isPersistedIntent(parsed) || parsed.intent.intendedSessionId !== intendedSessionId) return undefined;
|
|
373
|
+
if (this.#now() >= parsed.intent.expiresAt) return undefined;
|
|
374
|
+
this.#intents.set(intendedSessionId, parsed.intent);
|
|
375
|
+
return parsed.intent;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async readPendingTopic(topicId: number): Promise<TelegramPendingTopic | undefined> {
|
|
379
|
+
const file = pendingTopicFilePath(this.#agentDir, topicId);
|
|
380
|
+
const pending = await this.#readPendingSidecar(file);
|
|
381
|
+
if (!pending || pending.topicId !== topicId || this.#now() >= pending.expiresAt) return undefined;
|
|
382
|
+
this.#pendingTopics.set(topicId, pending);
|
|
383
|
+
return pending;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Rehydrate all non-expired intents from the notifications dir. Files are the
|
|
388
|
+
* only source: missing/corrupt/expired entries are skipped (never thrown).
|
|
389
|
+
* Returns the count of live intents loaded.
|
|
390
|
+
*/
|
|
391
|
+
async rehydrate(): Promise<number> {
|
|
392
|
+
let names: readonly string[];
|
|
393
|
+
try {
|
|
394
|
+
names = await this.#fsImpl.readdir(this.#dir);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (isMissing(error)) return 0;
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
let loaded = 0;
|
|
400
|
+
for (const name of names) {
|
|
401
|
+
if (name.endsWith(INTENT_FILE_SUFFIX)) {
|
|
402
|
+
const intendedSessionId = name.slice(0, -INTENT_FILE_SUFFIX.length);
|
|
403
|
+
if (!intendedSessionId) continue;
|
|
404
|
+
const intent = await this.readIntent(intendedSessionId);
|
|
405
|
+
if (intent) loaded++;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (!name.endsWith(PENDING_TOPIC_FILE_SUFFIX)) continue;
|
|
409
|
+
const rawTopicId = name.slice(0, -PENDING_TOPIC_FILE_SUFFIX.length);
|
|
410
|
+
const topicId = Number(rawTopicId);
|
|
411
|
+
if (!Number.isSafeInteger(topicId) || topicId <= 0) continue;
|
|
412
|
+
const pending = await this.readPendingTopic(topicId);
|
|
413
|
+
if (pending) loaded++;
|
|
414
|
+
}
|
|
415
|
+
return loaded;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Remove a single intent sidecar and drop its in-memory entry. Safe to call
|
|
420
|
+
* after a successful registry commit or a definite spawn failure. Missing
|
|
421
|
+
* files are not an error. Never calls the Telegram API.
|
|
422
|
+
*/
|
|
423
|
+
async remove(intendedSessionId: string): Promise<void> {
|
|
424
|
+
const file = adoptionIntentFilePath(this.#agentDir, intendedSessionId);
|
|
425
|
+
try {
|
|
426
|
+
await this.#fsImpl.unlink(file);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
if (!isMissing(error)) throw error;
|
|
429
|
+
}
|
|
430
|
+
const intent = this.#intents.get(intendedSessionId);
|
|
431
|
+
this.#intents.delete(intendedSessionId);
|
|
432
|
+
if (intent) this.releaseClaim(intent.topicId, intendedSessionId);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Remove a pending-topic authorization after an adoption intent is durable. */
|
|
436
|
+
async removePendingTopic(topicId: number): Promise<void> {
|
|
437
|
+
const file = pendingTopicFilePath(this.#agentDir, topicId);
|
|
438
|
+
try {
|
|
439
|
+
await this.#fsImpl.unlink(file);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (!isMissing(error)) throw error;
|
|
442
|
+
}
|
|
443
|
+
this.#pendingTopics.delete(topicId);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Sweep expired intent sidecars from disk. Touches files only. Returns the
|
|
448
|
+
* count of expired sidecars removed. Safe to run at startup and periodically.
|
|
449
|
+
*/
|
|
450
|
+
async sweepExpired(): Promise<number> {
|
|
451
|
+
let names: readonly string[];
|
|
452
|
+
try {
|
|
453
|
+
names = await this.#fsImpl.readdir(this.#dir);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (isMissing(error)) return 0;
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
const now = this.#now();
|
|
459
|
+
let removed = 0;
|
|
460
|
+
for (const name of names) {
|
|
461
|
+
const file = path.join(this.#dir, name);
|
|
462
|
+
if (name.endsWith(INTENT_FILE_SUFFIX)) {
|
|
463
|
+
const intendedSessionId = name.slice(0, -INTENT_FILE_SUFFIX.length);
|
|
464
|
+
if (!intendedSessionId) continue;
|
|
465
|
+
const intent = await this.#readSidecar(file);
|
|
466
|
+
if (intent && intent.intendedSessionId === intendedSessionId && now < intent.expiresAt) continue;
|
|
467
|
+
try {
|
|
468
|
+
await this.#fsImpl.unlink(file);
|
|
469
|
+
removed++;
|
|
470
|
+
} catch (error) {
|
|
471
|
+
if (!isMissing(error)) throw error;
|
|
472
|
+
}
|
|
473
|
+
if (intent) {
|
|
474
|
+
this.#intents.delete(intent.intendedSessionId);
|
|
475
|
+
this.releaseClaim(intent.topicId, intent.intendedSessionId);
|
|
476
|
+
}
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (!name.endsWith(PENDING_TOPIC_FILE_SUFFIX)) continue;
|
|
480
|
+
const rawTopicId = name.slice(0, -PENDING_TOPIC_FILE_SUFFIX.length);
|
|
481
|
+
const topicId = Number(rawTopicId);
|
|
482
|
+
const pending = await this.#readPendingSidecar(file);
|
|
483
|
+
if (Number.isSafeInteger(topicId) && topicId > 0 && pending?.topicId === topicId && now < pending.expiresAt)
|
|
484
|
+
continue;
|
|
485
|
+
try {
|
|
486
|
+
await this.#fsImpl.unlink(file);
|
|
487
|
+
removed++;
|
|
488
|
+
} catch (error) {
|
|
489
|
+
if (!isMissing(error)) throw error;
|
|
490
|
+
}
|
|
491
|
+
if (Number.isSafeInteger(topicId)) this.#pendingTopics.delete(topicId);
|
|
492
|
+
}
|
|
493
|
+
return removed;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Read and validate a sidecar at an arbitrary path (used by sweep). */
|
|
497
|
+
async #readSidecar(file: string): Promise<TelegramAdoptionIntent | undefined> {
|
|
498
|
+
let raw: string;
|
|
499
|
+
try {
|
|
500
|
+
raw = await this.#fsImpl.readFile(file, "utf8");
|
|
501
|
+
} catch (error) {
|
|
502
|
+
if (isMissing(error)) return undefined;
|
|
503
|
+
throw error;
|
|
504
|
+
}
|
|
505
|
+
let parsed: unknown;
|
|
506
|
+
try {
|
|
507
|
+
parsed = JSON.parse(raw);
|
|
508
|
+
} catch {
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
return isPersistedIntent(parsed) ? parsed.intent : undefined;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async #readPendingSidecar(file: string): Promise<TelegramPendingTopic | undefined> {
|
|
515
|
+
let raw: string;
|
|
516
|
+
try {
|
|
517
|
+
raw = await this.#fsImpl.readFile(file, "utf8");
|
|
518
|
+
} catch (error) {
|
|
519
|
+
if (isMissing(error)) return undefined;
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
let parsed: unknown;
|
|
523
|
+
try {
|
|
524
|
+
parsed = JSON.parse(raw);
|
|
525
|
+
} catch {
|
|
526
|
+
return undefined;
|
|
527
|
+
}
|
|
528
|
+
return isPersistedPendingTopic(parsed) ? parsed.pendingTopic : undefined;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** fsync the parent dir so the rename survives a crash; best-effort on win32. */
|
|
532
|
+
async #syncParentDirectory(): Promise<void> {
|
|
533
|
+
let handle: AdoptionIntentFileHandle;
|
|
534
|
+
try {
|
|
535
|
+
handle = await this.#fsImpl.open(this.#dir, "r");
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (this.#platform === "win32" && isUnsupportedDirectoryBarrierError(error)) return;
|
|
538
|
+
throw error;
|
|
539
|
+
}
|
|
540
|
+
let syncError: unknown;
|
|
541
|
+
try {
|
|
542
|
+
await syncRequired(handle);
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (!(this.#platform === "win32" && isUnsupportedDirectoryBarrierError(error))) syncError = error;
|
|
545
|
+
}
|
|
546
|
+
let closeError: unknown;
|
|
547
|
+
try {
|
|
548
|
+
await handle.close();
|
|
549
|
+
} catch (error) {
|
|
550
|
+
closeError = error;
|
|
551
|
+
}
|
|
552
|
+
if (syncError && closeError)
|
|
553
|
+
throw new AggregateError([syncError, closeError], "Parent directory sync and close failed");
|
|
554
|
+
if (syncError) throw syncError;
|
|
555
|
+
if (closeError) throw closeError;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function syncRequired(handle: AdoptionIntentFileHandle): Promise<void> {
|
|
560
|
+
if (typeof handle.sync !== "function")
|
|
561
|
+
throw new Error("adoption sidecar durability requires filesystem sync support");
|
|
562
|
+
await handle.sync();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function isMissing(error: unknown): error is NodeJS.ErrnoException {
|
|
566
|
+
return isRecord(error) && error.code === "ENOENT";
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function isUnsupportedDirectoryBarrierError(error: unknown): boolean {
|
|
570
|
+
return (
|
|
571
|
+
isRecord(error) &&
|
|
572
|
+
(error.code === "EINVAL" || error.code === "ENOTSUP" || error.code === "EOPNOTSUPP" || error.code === "EPERM")
|
|
573
|
+
);
|
|
574
|
+
}
|
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
|
|
20
20
|
import * as fs from "node:fs";
|
|
21
21
|
import * as path from "node:path";
|
|
22
|
-
import { getAgentDir } from "@gajae-code/utils";
|
|
22
|
+
import { $credentialEnv, getAgentDir } from "@gajae-code/utils";
|
|
23
23
|
import { tokenFingerprint } from "./config";
|
|
24
24
|
import { isFreshLiveOwner, readOwnerFreshnessSnapshot } from "./telegram-daemon";
|
|
25
|
-
import { runTelegramReferenceClient } from "./telegram-reference";
|
|
25
|
+
import { runTelegramReferenceClient, type TelegramNotificationSound } from "./telegram-reference";
|
|
26
26
|
|
|
27
27
|
interface CliArgs {
|
|
28
28
|
botToken?: string;
|
|
@@ -31,11 +31,14 @@ interface CliArgs {
|
|
|
31
31
|
sessionId?: string;
|
|
32
32
|
repo: string;
|
|
33
33
|
apiBase?: string;
|
|
34
|
+
sound: TelegramNotificationSound;
|
|
34
35
|
force: boolean;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
class CliUsageError extends Error {}
|
|
39
|
+
|
|
40
|
+
export function parseArgs(argv: string[]): CliArgs {
|
|
41
|
+
const args: CliArgs = { repo: process.cwd(), sound: "all", force: false };
|
|
39
42
|
for (let i = 0; i < argv.length; i++) {
|
|
40
43
|
const next = () => argv[++i];
|
|
41
44
|
switch (argv[i]) {
|
|
@@ -57,6 +60,14 @@ function parseArgs(argv: string[]): CliArgs {
|
|
|
57
60
|
case "--api-base":
|
|
58
61
|
args.apiBase = next();
|
|
59
62
|
break;
|
|
63
|
+
case "--sound": {
|
|
64
|
+
const sound = next();
|
|
65
|
+
if (sound !== "all" && sound !== "important" && sound !== "none") {
|
|
66
|
+
throw new CliUsageError("--sound must be all, important, or none");
|
|
67
|
+
}
|
|
68
|
+
args.sound = sound;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
60
71
|
case "--force":
|
|
61
72
|
args.force = true;
|
|
62
73
|
break;
|
|
@@ -82,6 +93,7 @@ function printHelpAndExit(): never {
|
|
|
82
93
|
" --session-id <id> Resolve <repo>/.gjc/state/sdk/<id>.json",
|
|
83
94
|
" --repo <dir> Repo root for endpoint discovery (default: cwd)",
|
|
84
95
|
" --api-base <url> Telegram API base (default: https://api.telegram.org)",
|
|
96
|
+
" --sound <all|important|none> Telegram notification sound (default: all)",
|
|
85
97
|
" --force Bypass active daemon guard (debug only; may cause Telegram 409 conflicts)",
|
|
86
98
|
"",
|
|
87
99
|
].join("\n"),
|
|
@@ -149,9 +161,22 @@ async function activeDaemonOwnsToken(input: { botToken: string; chatId: string }
|
|
|
149
161
|
}
|
|
150
162
|
|
|
151
163
|
async function main(): Promise<void> {
|
|
152
|
-
|
|
164
|
+
let args: CliArgs;
|
|
165
|
+
try {
|
|
166
|
+
args = parseArgs(process.argv.slice(2));
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error instanceof CliUsageError) {
|
|
169
|
+
process.stderr.write(`error: ${error.message}\n`);
|
|
170
|
+
process.exit(2);
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
153
174
|
const apiBase = args.apiBase ?? "https://api.telegram.org";
|
|
154
|
-
|
|
175
|
+
// Trusted sources only: this token is the bot the client talks to, so whatever
|
|
176
|
+
// can set it receives the session's notifications and the operator's replies.
|
|
177
|
+
// `$env` merges the caller's `cwd/.env` into `process.env`, and this client is
|
|
178
|
+
// normally run from inside a repository.
|
|
179
|
+
const botToken = args.botToken ?? $credentialEnv("GJC_TG_BOT_TOKEN");
|
|
155
180
|
if (!botToken) {
|
|
156
181
|
process.stderr.write("error: --bot-token (or GJC_TG_BOT_TOKEN) is required\n");
|
|
157
182
|
process.exit(2);
|
|
@@ -168,7 +193,7 @@ async function main(): Promise<void> {
|
|
|
168
193
|
process.exit(2);
|
|
169
194
|
}
|
|
170
195
|
|
|
171
|
-
const chatId = args.chatId ??
|
|
196
|
+
const chatId = args.chatId ?? $credentialEnv("GJC_TG_CHAT_ID") ?? (await resolveChatId(botToken, apiBase));
|
|
172
197
|
if (!args.force && (await activeDaemonOwnsToken({ botToken, chatId }))) {
|
|
173
198
|
process.stderr.write(
|
|
174
199
|
"an active gjc notifications daemon already owns this bot token; running a second poller will cause Telegram 409 conflicts. Re-run with --force to override.\n",
|
|
@@ -176,7 +201,7 @@ async function main(): Promise<void> {
|
|
|
176
201
|
process.exit(1);
|
|
177
202
|
}
|
|
178
203
|
process.stderr.write(`notifications: bridging ${endpointFile} <-> Telegram chat ${chatId}\n`);
|
|
179
|
-
await runTelegramReferenceClient({ botToken, chatId, endpointFile, apiBase });
|
|
204
|
+
await runTelegramReferenceClient({ botToken, chatId, endpointFile, apiBase, sound: args.sound });
|
|
180
205
|
}
|
|
181
206
|
|
|
182
207
|
if (import.meta.main) {
|
|
@@ -232,6 +232,7 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
|
|
|
232
232
|
botToken: cfg.botToken,
|
|
233
233
|
chatId: cfg.chatId,
|
|
234
234
|
idleTimeoutMs: cfg.idleTimeoutMs,
|
|
235
|
+
sound: cfg.sound,
|
|
235
236
|
rich: cfg.rich,
|
|
236
237
|
richDraft: cfg.richDraft,
|
|
237
238
|
toolActivity: cfg.toolActivity,
|