ftown-bridge 0.19.1 → 0.19.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/dist/agent-commands.d.ts +3 -17
- package/dist/agent-commands.js +8 -94
- package/dist/agent-commands.js.map +1 -1
- package/dist/bridge-auth.d.ts +21 -0
- package/dist/bridge-auth.js +48 -0
- package/dist/bridge-auth.js.map +1 -0
- package/dist/centrifugo-client.js +39 -0
- package/dist/centrifugo-client.js.map +1 -1
- package/dist/command-rpc.d.ts +15 -0
- package/dist/command-rpc.js +249 -0
- package/dist/command-rpc.js.map +1 -0
- package/dist/create-ftown-session.d.ts +43 -0
- package/dist/create-ftown-session.js +115 -9
- package/dist/create-ftown-session.js.map +1 -1
- package/dist/ftown-sessions-cli.js +74 -4
- package/dist/ftown-sessions-cli.js.map +1 -1
- package/dist/harness-cli.js +1 -13
- package/dist/harness-cli.js.map +1 -1
- package/dist/harness-installer.js +4 -2
- package/dist/harness-installer.js.map +1 -1
- package/dist/harness-registry.d.ts +189 -0
- package/dist/harness-registry.js +218 -0
- package/dist/harness-registry.js.map +1 -0
- package/dist/index.js +71 -721
- package/dist/index.js.map +1 -1
- package/dist/install-ftown-cli.js +12 -3
- package/dist/install-ftown-cli.js.map +1 -1
- package/dist/local-api-server.d.ts +14 -13
- package/dist/local-api-server.js +180 -424
- package/dist/local-api-server.js.map +1 -1
- package/dist/loop-controller.d.ts +87 -0
- package/dist/loop-controller.js +133 -0
- package/dist/loop-controller.js.map +1 -0
- package/dist/loop-run-store.js +2 -8
- package/dist/loop-run-store.js.map +1 -1
- package/dist/loop-validation.d.ts +1 -0
- package/dist/loop-validation.js +3 -8
- package/dist/loop-validation.js.map +1 -1
- package/dist/mail-delivery.d.ts +114 -0
- package/dist/mail-delivery.js +315 -0
- package/dist/mail-delivery.js.map +1 -0
- package/dist/session-controller.d.ts +107 -0
- package/dist/session-controller.js +141 -0
- package/dist/session-controller.js.map +1 -0
- package/dist/session-ids.d.ts +19 -0
- package/dist/session-ids.js +72 -0
- package/dist/session-ids.js.map +1 -0
- package/dist/session-resurrection.d.ts +24 -0
- package/dist/session-resurrection.js +128 -0
- package/dist/session-resurrection.js.map +1 -1
- package/dist/spawn-stagger.d.ts +22 -0
- package/dist/spawn-stagger.js +34 -0
- package/dist/spawn-stagger.js.map +1 -0
- package/dist/terminal-pump.d.ts +61 -0
- package/dist/terminal-pump.js +193 -0
- package/dist/terminal-pump.js.map +1 -0
- package/dist/types.d.ts +29 -4
- package/dist/usage-collector.d.ts +42 -0
- package/dist/usage-collector.js +317 -0
- package/dist/usage-collector.js.map +1 -0
- package/dist/wire-types.d.ts +124 -0
- package/dist/wire-types.js +28 -0
- package/dist/wire-types.js.map +1 -0
- package/dist/workflow-runner-cli.js +2 -1
- package/dist/workflow-runner-cli.js.map +1 -1
- package/dist/workflow-runner.d.ts +2 -1
- package/dist/workflow-runner.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { MailMessage, Session } from './types.js';
|
|
2
|
+
export declare const MAX_MAIL_BODY_LENGTH: number;
|
|
3
|
+
export declare const MAX_MAIL_WAIT_SECONDS = 30;
|
|
4
|
+
export declare const MAIL_NUDGE_DELAY_MS = 5000;
|
|
5
|
+
export declare const MAIL_NUDGE_DELAY_HOOKED_MS = 60000;
|
|
6
|
+
export declare const MAIL_NUDGE_MIN_INTERVAL_MS = 30000;
|
|
7
|
+
export declare const MAIL_NUDGE_BUSY_RECHECK_MS = 60000;
|
|
8
|
+
export declare const AGENT_BUSY_STALE_MS: number;
|
|
9
|
+
export declare const MAIL_TYPES: ReadonlyArray<MailMessage['type']>;
|
|
10
|
+
export declare function sanitizeMessageText(text: string): string;
|
|
11
|
+
/** The slice of SessionStore the mail engine needs. */
|
|
12
|
+
export interface MailDeliverySessionStore {
|
|
13
|
+
loadSession(sessionId: string): Promise<Session | null>;
|
|
14
|
+
listSessions(): Promise<Session[]>;
|
|
15
|
+
}
|
|
16
|
+
/** The slice of MailStore the mail engine needs. */
|
|
17
|
+
export interface MailDeliveryMailStore {
|
|
18
|
+
append(msg: MailMessage): Promise<void>;
|
|
19
|
+
listUndelivered(sessionId: string): Promise<MailMessage[]>;
|
|
20
|
+
listAll(sessionId: string, limit: number): Promise<MailMessage[]>;
|
|
21
|
+
markDelivered(sessionId: string, ids: string[], via: NonNullable<MailMessage['deliveredVia']>): Promise<MailMessage[]>;
|
|
22
|
+
}
|
|
23
|
+
/** The slice of ProcessRunner the mail engine needs. */
|
|
24
|
+
export interface MailDeliveryRunner {
|
|
25
|
+
isRunning(sessionId: string): boolean;
|
|
26
|
+
write(sessionId: string, data: string): boolean;
|
|
27
|
+
}
|
|
28
|
+
/** Timer handle as returned by the injected setTimeout (Node's supports unref). */
|
|
29
|
+
export type MailTimerHandle = {
|
|
30
|
+
unref?: () => void;
|
|
31
|
+
};
|
|
32
|
+
export interface MailDeliveryOptions {
|
|
33
|
+
store?: MailDeliverySessionStore | null;
|
|
34
|
+
mailStore?: MailDeliveryMailStore | null;
|
|
35
|
+
runner?: MailDeliveryRunner | null;
|
|
36
|
+
/** Clock seam — defaults to the real Date.now/setTimeout/clearTimeout. */
|
|
37
|
+
now?: () => number;
|
|
38
|
+
setTimeout?: (fn: () => void, ms: number) => MailTimerHandle;
|
|
39
|
+
clearTimeout?: (handle: MailTimerHandle) => void;
|
|
40
|
+
}
|
|
41
|
+
export type AcceptMailResult = {
|
|
42
|
+
ok: true;
|
|
43
|
+
id: string;
|
|
44
|
+
} | {
|
|
45
|
+
ok: false;
|
|
46
|
+
error: string;
|
|
47
|
+
};
|
|
48
|
+
export type InboxReadResult = {
|
|
49
|
+
kind: 'immediate';
|
|
50
|
+
messages: MailMessage[];
|
|
51
|
+
} | {
|
|
52
|
+
kind: 'longpoll';
|
|
53
|
+
messages: Promise<MailMessage[]>;
|
|
54
|
+
abandon: () => void;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The bridge's mail-delivery engine, extracted from the HTTP router so its
|
|
58
|
+
* timing logic (long-poll waiters, nudge debounce/rate-limit/busy-recheck,
|
|
59
|
+
* composer-paste injection) is unit-testable with a controlled clock.
|
|
60
|
+
*
|
|
61
|
+
* Owns ALL mail state: one long-poll waiter per session, nudge timers,
|
|
62
|
+
* pending-nudge sender labels, last-nudge timestamps, and agent busy markers.
|
|
63
|
+
* The HTTP server keeps route parsing/response writing and delegates here.
|
|
64
|
+
*/
|
|
65
|
+
export declare class MailDeliveryService {
|
|
66
|
+
private store;
|
|
67
|
+
private mailStore;
|
|
68
|
+
private runner;
|
|
69
|
+
private readonly now;
|
|
70
|
+
private readonly setTimeoutFn;
|
|
71
|
+
private readonly clearTimeoutFn;
|
|
72
|
+
private mailWaiters;
|
|
73
|
+
private nudgeTimers;
|
|
74
|
+
private pendingNudgeFrom;
|
|
75
|
+
private lastNudgeAt;
|
|
76
|
+
private agentBusySince;
|
|
77
|
+
constructor(options?: MailDeliveryOptions);
|
|
78
|
+
setStore(store: MailDeliverySessionStore): void;
|
|
79
|
+
setMailStore(mailStore: MailDeliveryMailStore): void;
|
|
80
|
+
setRunner(runner: MailDeliveryRunner): void;
|
|
81
|
+
/** Both the session store and the mail store are wired. */
|
|
82
|
+
isReady(): boolean;
|
|
83
|
+
/** Clear all timers and resolve every pending long poll with []. */
|
|
84
|
+
stop(): void;
|
|
85
|
+
/** Hook activity (UserPromptSubmit/PreToolUse/PostToolUse): the agent is mid-turn. */
|
|
86
|
+
markAgentBusy(sessionId: string): void;
|
|
87
|
+
/** Hook Stop/SessionEnd: the agent's turn ended. */
|
|
88
|
+
markAgentIdle(sessionId: string): void;
|
|
89
|
+
injectPtyLine(session: Session, sender: string, text: string): Promise<boolean>;
|
|
90
|
+
/**
|
|
91
|
+
* Store incoming mail for a session: validate, append, then either kick the
|
|
92
|
+
* session's long-poll waiter or schedule an idle nudge.
|
|
93
|
+
*/
|
|
94
|
+
acceptMail(session: Session, body: Record<string, unknown>): Promise<AcceptMailResult>;
|
|
95
|
+
/**
|
|
96
|
+
* Read a session's inbox: peek, drain, or long poll. A long poll returns a
|
|
97
|
+
* promise that resolves when mail arrives (or the wait elapses) plus an
|
|
98
|
+
* `abandon` callback the caller must invoke if the client disconnects —
|
|
99
|
+
* abandoning never marks messages delivered, so no mail is lost.
|
|
100
|
+
*/
|
|
101
|
+
readMail(session: Session, query: {
|
|
102
|
+
wait: number;
|
|
103
|
+
peek: boolean;
|
|
104
|
+
all: boolean;
|
|
105
|
+
limit: number;
|
|
106
|
+
}): Promise<InboxReadResult>;
|
|
107
|
+
/** A session earns the Stop-hook listen window only if mail can plausibly
|
|
108
|
+
* arrive: it has a parent, has children, was marked an orchestrator, or has
|
|
109
|
+
* exchanged mail before. */
|
|
110
|
+
participatesInMail(session: Session): Promise<boolean>;
|
|
111
|
+
private scheduleMailNudge;
|
|
112
|
+
private fireMailNudge;
|
|
113
|
+
private delay;
|
|
114
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { createMailMessage } from './mail-store.js';
|
|
2
|
+
import { HOOKED_SHELL_TYPES } from './harness-registry.js';
|
|
3
|
+
export const MAX_MAIL_BODY_LENGTH = 64 * 1024;
|
|
4
|
+
export const MAX_MAIL_WAIT_SECONDS = 30;
|
|
5
|
+
export const MAIL_NUDGE_DELAY_MS = 5_000;
|
|
6
|
+
// Hooked agents (claude/codex and claude flavors) get mail through their Stop
|
|
7
|
+
// pump; the nudge is only a safety net for them, so wait much longer before
|
|
8
|
+
// typing into their pane — fast nudges race the pump and queue stale prompts.
|
|
9
|
+
export const MAIL_NUDGE_DELAY_HOOKED_MS = 60_000;
|
|
10
|
+
export const MAIL_NUDGE_MIN_INTERVAL_MS = 30_000;
|
|
11
|
+
// While an agent is mid-turn its Stop hook pump delivers mail at turn end, so
|
|
12
|
+
// nudging would only queue a stale prompt; re-check periodically in case the
|
|
13
|
+
// turn never reaches Stop, and ignore busy markers old enough to be a crash.
|
|
14
|
+
export const MAIL_NUDGE_BUSY_RECHECK_MS = 60_000;
|
|
15
|
+
export const AGENT_BUSY_STALE_MS = 30 * 60_000;
|
|
16
|
+
export const MAIL_TYPES = ['message', 'task', 'result', 'escalation'];
|
|
17
|
+
// Composer TUIs detect pastes by input arrival rate; the submit CR must come well
|
|
18
|
+
// after that window or it is treated as a pasted newline.
|
|
19
|
+
const COMPOSER_PASTE_SETTLE_MS = 600;
|
|
20
|
+
// Messages are sanitized to a single line, so a delayed plain CR submits everywhere.
|
|
21
|
+
// (ESC+CR reads as Alt+Enter — insert newline — on current Claude Code.)
|
|
22
|
+
function submitSuffixFor(_shellType) {
|
|
23
|
+
return '\r';
|
|
24
|
+
}
|
|
25
|
+
// Strip control chars and collapse whitespace so a message cannot inject keystrokes.
|
|
26
|
+
export function sanitizeMessageText(text) {
|
|
27
|
+
return text
|
|
28
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
29
|
+
.replace(/\s+/g, ' ')
|
|
30
|
+
.trim();
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The bridge's mail-delivery engine, extracted from the HTTP router so its
|
|
34
|
+
* timing logic (long-poll waiters, nudge debounce/rate-limit/busy-recheck,
|
|
35
|
+
* composer-paste injection) is unit-testable with a controlled clock.
|
|
36
|
+
*
|
|
37
|
+
* Owns ALL mail state: one long-poll waiter per session, nudge timers,
|
|
38
|
+
* pending-nudge sender labels, last-nudge timestamps, and agent busy markers.
|
|
39
|
+
* The HTTP server keeps route parsing/response writing and delegates here.
|
|
40
|
+
*/
|
|
41
|
+
export class MailDeliveryService {
|
|
42
|
+
store;
|
|
43
|
+
mailStore;
|
|
44
|
+
runner;
|
|
45
|
+
now;
|
|
46
|
+
setTimeoutFn;
|
|
47
|
+
clearTimeoutFn;
|
|
48
|
+
mailWaiters = new Map();
|
|
49
|
+
nudgeTimers = new Map();
|
|
50
|
+
pendingNudgeFrom = new Map();
|
|
51
|
+
lastNudgeAt = new Map();
|
|
52
|
+
agentBusySince = new Map();
|
|
53
|
+
constructor(options = {}) {
|
|
54
|
+
this.store = options.store ?? null;
|
|
55
|
+
this.mailStore = options.mailStore ?? null;
|
|
56
|
+
this.runner = options.runner ?? null;
|
|
57
|
+
this.now = options.now ?? (() => Date.now());
|
|
58
|
+
this.setTimeoutFn = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
|
|
59
|
+
this.clearTimeoutFn = options.clearTimeout ?? ((handle) => clearTimeout(handle));
|
|
60
|
+
}
|
|
61
|
+
setStore(store) {
|
|
62
|
+
this.store = store;
|
|
63
|
+
}
|
|
64
|
+
setMailStore(mailStore) {
|
|
65
|
+
this.mailStore = mailStore;
|
|
66
|
+
}
|
|
67
|
+
setRunner(runner) {
|
|
68
|
+
this.runner = runner;
|
|
69
|
+
}
|
|
70
|
+
/** Both the session store and the mail store are wired. */
|
|
71
|
+
isReady() {
|
|
72
|
+
return this.store !== null && this.mailStore !== null;
|
|
73
|
+
}
|
|
74
|
+
/** Clear all timers and resolve every pending long poll with []. */
|
|
75
|
+
stop() {
|
|
76
|
+
for (const timer of this.nudgeTimers.values())
|
|
77
|
+
this.clearTimeoutFn(timer);
|
|
78
|
+
this.nudgeTimers.clear();
|
|
79
|
+
for (const waiter of [...this.mailWaiters.values()])
|
|
80
|
+
waiter.deliver([]);
|
|
81
|
+
this.mailWaiters.clear();
|
|
82
|
+
}
|
|
83
|
+
/** Hook activity (UserPromptSubmit/PreToolUse/PostToolUse): the agent is mid-turn. */
|
|
84
|
+
markAgentBusy(sessionId) {
|
|
85
|
+
this.agentBusySince.set(sessionId, this.now());
|
|
86
|
+
}
|
|
87
|
+
/** Hook Stop/SessionEnd: the agent's turn ended. */
|
|
88
|
+
markAgentIdle(sessionId) {
|
|
89
|
+
this.agentBusySince.delete(sessionId);
|
|
90
|
+
}
|
|
91
|
+
// Plain shells would execute the message as a command; deliver it as a quoted no-op
|
|
92
|
+
// (`: '...'`) — interactive zsh has no '#' comments by default.
|
|
93
|
+
async injectPtyLine(session, sender, text) {
|
|
94
|
+
if (!this.runner)
|
|
95
|
+
return false;
|
|
96
|
+
const isAgent = session.shellType && session.shellType !== 'shell';
|
|
97
|
+
const line = isAgent
|
|
98
|
+
? `[ftown msg from ${sender}] ${text}`
|
|
99
|
+
: `: '[ftown msg from ${sender}] ${text.replace(/'/g, '')}'`;
|
|
100
|
+
if (!this.runner.write(session.id, line))
|
|
101
|
+
return false;
|
|
102
|
+
// Composer TUIs detect pastes by input arrival rate; the submit CR must come well
|
|
103
|
+
// after that window or it is treated as a pasted newline.
|
|
104
|
+
await this.delay(COMPOSER_PASTE_SETTLE_MS);
|
|
105
|
+
this.runner.write(session.id, submitSuffixFor(session.shellType));
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Store incoming mail for a session: validate, append, then either kick the
|
|
110
|
+
* session's long-poll waiter or schedule an idle nudge.
|
|
111
|
+
*/
|
|
112
|
+
async acceptMail(session, body) {
|
|
113
|
+
if (!this.mailStore) {
|
|
114
|
+
return { ok: false, error: 'Mail store not ready' };
|
|
115
|
+
}
|
|
116
|
+
const text = body.body;
|
|
117
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
118
|
+
return { ok: false, error: 'Missing body' };
|
|
119
|
+
}
|
|
120
|
+
if (text.length > MAX_MAIL_BODY_LENGTH) {
|
|
121
|
+
return { ok: false, error: `Body too long (max ${MAX_MAIL_BODY_LENGTH} chars)` };
|
|
122
|
+
}
|
|
123
|
+
let type = 'message';
|
|
124
|
+
if (body.type !== undefined) {
|
|
125
|
+
if (typeof body.type !== 'string' || !MAIL_TYPES.includes(body.type)) {
|
|
126
|
+
return { ok: false, error: `Invalid type (expected one of: ${MAIL_TYPES.join(', ')})` };
|
|
127
|
+
}
|
|
128
|
+
type = body.type;
|
|
129
|
+
}
|
|
130
|
+
const from = typeof body.from === 'string' && body.from.trim() ? body.from.trim() : 'external';
|
|
131
|
+
const fromName = typeof body.fromName === 'string' && body.fromName.trim() ? body.fromName.trim() : undefined;
|
|
132
|
+
const threadId = typeof body.threadId === 'string' && body.threadId.trim() ? body.threadId.trim() : undefined;
|
|
133
|
+
const msg = createMailMessage({ from, fromName, to: session.id, type, threadId, body: text });
|
|
134
|
+
await this.mailStore.append(msg);
|
|
135
|
+
const waiter = this.mailWaiters.get(session.id);
|
|
136
|
+
if (waiter) {
|
|
137
|
+
const undelivered = await this.mailStore.listUndelivered(session.id);
|
|
138
|
+
// Mirror the timeout path's settled-guard: the waiter may have died
|
|
139
|
+
// (client disconnected, or a newer waiter replaced it) while we awaited
|
|
140
|
+
// above. Only mark messages delivered if it is still the live waiter —
|
|
141
|
+
// otherwise leave them undelivered so the next reader gets them instead
|
|
142
|
+
// of writing to a dead socket and losing them.
|
|
143
|
+
if (this.mailWaiters.get(session.id) === waiter && undelivered.length > 0) {
|
|
144
|
+
const marked = await this.mailStore.markDelivered(session.id, undelivered.map((m) => m.id), 'poll');
|
|
145
|
+
waiter.deliver(marked);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
const nudgeDelay = HOOKED_SHELL_TYPES.has(session.shellType ?? 'claude')
|
|
150
|
+
? MAIL_NUDGE_DELAY_HOOKED_MS
|
|
151
|
+
: MAIL_NUDGE_DELAY_MS;
|
|
152
|
+
this.scheduleMailNudge(session.id, fromName ?? from, nudgeDelay);
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, id: msg.id };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Read a session's inbox: peek, drain, or long poll. A long poll returns a
|
|
158
|
+
* promise that resolves when mail arrives (or the wait elapses) plus an
|
|
159
|
+
* `abandon` callback the caller must invoke if the client disconnects —
|
|
160
|
+
* abandoning never marks messages delivered, so no mail is lost.
|
|
161
|
+
*/
|
|
162
|
+
async readMail(session, query) {
|
|
163
|
+
const mailStore = this.mailStore;
|
|
164
|
+
if (!mailStore) {
|
|
165
|
+
return { kind: 'immediate', messages: [] };
|
|
166
|
+
}
|
|
167
|
+
const wait = Math.min(query.wait, MAX_MAIL_WAIT_SECONDS);
|
|
168
|
+
if (query.peek) {
|
|
169
|
+
const messages = query.all
|
|
170
|
+
? await mailStore.listAll(session.id, query.limit)
|
|
171
|
+
: await mailStore.listUndelivered(session.id);
|
|
172
|
+
return { kind: 'immediate', messages };
|
|
173
|
+
}
|
|
174
|
+
// The listen window is earned, not universal: a session with no mail
|
|
175
|
+
// relationships (no parent, no children, not an orchestrator) gets an
|
|
176
|
+
// instant stop instead of holding its Stop hook open for `wait` seconds.
|
|
177
|
+
// Late mail for everyone is still covered by the idle nudge.
|
|
178
|
+
let effectiveWait = wait;
|
|
179
|
+
if (wait > 0 && !(await this.participatesInMail(session))) {
|
|
180
|
+
effectiveWait = 0;
|
|
181
|
+
}
|
|
182
|
+
const undelivered = await mailStore.listUndelivered(session.id);
|
|
183
|
+
if (undelivered.length > 0 || effectiveWait <= 0) {
|
|
184
|
+
const marked = await mailStore.markDelivered(session.id, undelivered.map((m) => m.id), effectiveWait > 0 ? 'poll' : 'drain');
|
|
185
|
+
return { kind: 'immediate', messages: marked };
|
|
186
|
+
}
|
|
187
|
+
// Long poll: hold until mail arrives or `wait` seconds elapse. One waiter
|
|
188
|
+
// per session — a newer waiter resolves the previous one with [].
|
|
189
|
+
const existing = this.mailWaiters.get(session.id);
|
|
190
|
+
if (existing)
|
|
191
|
+
existing.deliver([]);
|
|
192
|
+
let settled = false;
|
|
193
|
+
let resolveMessages;
|
|
194
|
+
const messages = new Promise((resolve) => {
|
|
195
|
+
resolveMessages = resolve;
|
|
196
|
+
});
|
|
197
|
+
const waiter = {
|
|
198
|
+
deliver: (delivered) => {
|
|
199
|
+
if (settled)
|
|
200
|
+
return;
|
|
201
|
+
settled = true;
|
|
202
|
+
this.clearTimeoutFn(timer);
|
|
203
|
+
if (this.mailWaiters.get(session.id) === waiter) {
|
|
204
|
+
this.mailWaiters.delete(session.id);
|
|
205
|
+
}
|
|
206
|
+
resolveMessages(delivered);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
const timer = this.setTimeoutFn(() => {
|
|
210
|
+
// Guard each async step on settled: once the client disconnects (or a new
|
|
211
|
+
// waiter replaced this one) we must not mark messages delivered — they
|
|
212
|
+
// would be lost without ever reaching a session.
|
|
213
|
+
if (settled || this.mailWaiters.get(session.id) !== waiter)
|
|
214
|
+
return;
|
|
215
|
+
mailStore
|
|
216
|
+
.listUndelivered(session.id)
|
|
217
|
+
.then((pending) => {
|
|
218
|
+
if (settled || pending.length === 0)
|
|
219
|
+
return [];
|
|
220
|
+
return mailStore.markDelivered(session.id, pending.map((m) => m.id), 'poll');
|
|
221
|
+
})
|
|
222
|
+
.then((marked) => waiter.deliver(marked))
|
|
223
|
+
.catch(() => waiter.deliver([]));
|
|
224
|
+
}, effectiveWait * 1000);
|
|
225
|
+
this.mailWaiters.set(session.id, waiter);
|
|
226
|
+
const abandon = () => {
|
|
227
|
+
if (this.mailWaiters.get(session.id) !== waiter)
|
|
228
|
+
return;
|
|
229
|
+
settled = true;
|
|
230
|
+
this.clearTimeoutFn(timer);
|
|
231
|
+
this.mailWaiters.delete(session.id);
|
|
232
|
+
};
|
|
233
|
+
return { kind: 'longpoll', messages, abandon };
|
|
234
|
+
}
|
|
235
|
+
/** A session earns the Stop-hook listen window only if mail can plausibly
|
|
236
|
+
* arrive: it has a parent, has children, was marked an orchestrator, or has
|
|
237
|
+
* exchanged mail before. */
|
|
238
|
+
async participatesInMail(session) {
|
|
239
|
+
if (session.parentSessionId)
|
|
240
|
+
return true;
|
|
241
|
+
if (session.env?.FTOWN_ORCHESTRATOR === '1')
|
|
242
|
+
return true;
|
|
243
|
+
if (!this.store || !this.mailStore)
|
|
244
|
+
return false;
|
|
245
|
+
const sessions = await this.store.listSessions();
|
|
246
|
+
if (sessions.some((s) => s.parentSessionId === session.id))
|
|
247
|
+
return true;
|
|
248
|
+
const history = await this.mailStore.listAll(session.id, 1);
|
|
249
|
+
return history.length > 0;
|
|
250
|
+
}
|
|
251
|
+
// Debounced wake-up for mail that no long-poll consumed: after 5s, if it is still
|
|
252
|
+
// undelivered and the session is running, inject a one-line pointer to the
|
|
253
|
+
// harness mail command. At most one nudge per session per 30s; senders coalesce.
|
|
254
|
+
scheduleMailNudge(sessionId, fromLabel, delayMs = MAIL_NUDGE_DELAY_MS) {
|
|
255
|
+
this.pendingNudgeFrom.set(sessionId, fromLabel);
|
|
256
|
+
if (this.nudgeTimers.has(sessionId))
|
|
257
|
+
return;
|
|
258
|
+
const timer = this.setTimeoutFn(() => {
|
|
259
|
+
this.nudgeTimers.delete(sessionId);
|
|
260
|
+
this.fireMailNudge(sessionId).catch((err) => {
|
|
261
|
+
console.error('[MailDelivery] Mail nudge failed:', err instanceof Error ? err.message : String(err));
|
|
262
|
+
});
|
|
263
|
+
}, delayMs);
|
|
264
|
+
timer.unref?.();
|
|
265
|
+
this.nudgeTimers.set(sessionId, timer);
|
|
266
|
+
}
|
|
267
|
+
async fireMailNudge(sessionId) {
|
|
268
|
+
if (!this.store || !this.mailStore || !this.runner)
|
|
269
|
+
return;
|
|
270
|
+
const fromLabel = this.pendingNudgeFrom.get(sessionId) ?? 'ftown';
|
|
271
|
+
const undelivered = await this.mailStore.listUndelivered(sessionId);
|
|
272
|
+
if (undelivered.length === 0) {
|
|
273
|
+
this.pendingNudgeFrom.delete(sessionId);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const busySince = this.agentBusySince.get(sessionId);
|
|
277
|
+
if (busySince !== undefined && this.now() - busySince < AGENT_BUSY_STALE_MS) {
|
|
278
|
+
this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_BUSY_RECHECK_MS);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
// "Never nudged" must be its own state, not `lastNudgeAt ?? 0` — a clock
|
|
282
|
+
// that starts near zero (tests, or a freshly booted process) would make
|
|
283
|
+
// `now() - 0` look like it's already inside the rate-limit window.
|
|
284
|
+
const lastNudgeAt = this.lastNudgeAt.get(sessionId);
|
|
285
|
+
if (lastNudgeAt !== undefined) {
|
|
286
|
+
const sinceLast = this.now() - lastNudgeAt;
|
|
287
|
+
if (sinceLast < MAIL_NUDGE_MIN_INTERVAL_MS) {
|
|
288
|
+
// Rate-limited: retry once the window reopens (mail may still be unread).
|
|
289
|
+
this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_MIN_INTERVAL_MS - sinceLast);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// The session isn't running (crashed/restarting): don't clear the pending
|
|
294
|
+
// marker. No timer is armed for this session at this point (the caller
|
|
295
|
+
// already removed it before invoking us), so clearing it here would lose
|
|
296
|
+
// the sender label until unrelated new mail happens to call
|
|
297
|
+
// scheduleMailNudge again. Keeping it means the next opportunity to
|
|
298
|
+
// re-check this session (new mail via acceptMail, or an external
|
|
299
|
+
// restart/idle hook re-invoking this nudge check) still knows who to
|
|
300
|
+
// credit.
|
|
301
|
+
if (!this.runner.isRunning(sessionId))
|
|
302
|
+
return;
|
|
303
|
+
const session = await this.store.loadSession(sessionId);
|
|
304
|
+
if (!session)
|
|
305
|
+
return;
|
|
306
|
+
this.pendingNudgeFrom.delete(sessionId);
|
|
307
|
+
this.lastNudgeAt.set(sessionId, this.now());
|
|
308
|
+
const text = sanitizeMessageText(`You have new ftown mail from ${fromLabel} — run \`~/.ftown/bin/ftown-harness mail read\` to read it.`);
|
|
309
|
+
await this.injectPtyLine(session, 'ftown-mail', text);
|
|
310
|
+
}
|
|
311
|
+
delay(ms) {
|
|
312
|
+
return new Promise((resolve) => this.setTimeoutFn(resolve, ms));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
//# sourceMappingURL=mail-delivery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mail-delivery.js","sourceRoot":"","sources":["../src/mail-delivery.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { type CreateFtownSessionDeps, type CreateFtownSessionInput } from './create-ftown-session.js';
|
|
2
|
+
import type { RemoveFtownSessionOptions } from './remove-ftown-session.js';
|
|
3
|
+
import type { Session, SessionUsage } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Transport-agnostic session operations, defined ONCE and shared by the two
|
|
6
|
+
* dispatch surfaces (the Centrifugo RPC switch in index.ts and the local HTTP
|
|
7
|
+
* router in local-api-server.ts). Methods take already-parsed typed input and
|
|
8
|
+
* return typed results/errors; the adapters own all wire concerns (command
|
|
9
|
+
* envelopes, HTTP statuses, toWireSession payload shaping).
|
|
10
|
+
*
|
|
11
|
+
* Error-propagation contract (matches the pre-refactor adapters byte for byte):
|
|
12
|
+
* - `create` and `retry` let ProviderAuthMissingError / WorkingDirMissingError /
|
|
13
|
+
* generic Errors THROW — each adapter already owns a per-error-class mapping
|
|
14
|
+
* (RPC outer catch with the working-dir data envelope; HTTP 422/400/500).
|
|
15
|
+
* - State/lookup failures return typed `{ ok: false, code, message }` results.
|
|
16
|
+
*/
|
|
17
|
+
export type SessionErrorCode = 'not_found' | 'invalid' | 'conflict';
|
|
18
|
+
export interface SessionControllerError {
|
|
19
|
+
ok: false;
|
|
20
|
+
code: SessionErrorCode;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
export type SessionControllerResult<T> = ({
|
|
24
|
+
ok: true;
|
|
25
|
+
} & T) | SessionControllerError;
|
|
26
|
+
/** Combined rename/reparent input. `parent` PRESENT means "reparent"; a value
|
|
27
|
+
* of null/undefined/'' clears the parent (both dispatchers treat all three as
|
|
28
|
+
* clear). `value` is unknown so adapters can hand over unvalidated wire input. */
|
|
29
|
+
export interface SessionUpdateInput {
|
|
30
|
+
name?: string;
|
|
31
|
+
parent?: {
|
|
32
|
+
value: unknown;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Narrow structural slice of SessionStore so tests can use in-memory fakes. */
|
|
36
|
+
export interface SessionStoreLike {
|
|
37
|
+
loadSession(sessionId: string): Promise<Session | null>;
|
|
38
|
+
saveSession(session: Session): Promise<void>;
|
|
39
|
+
listSessions(): Promise<Session[]>;
|
|
40
|
+
clearTerminalLog(sessionId: string): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
export interface SessionControllerDeps {
|
|
43
|
+
store: SessionStoreLike;
|
|
44
|
+
runner: {
|
|
45
|
+
stop(sessionId: string): boolean;
|
|
46
|
+
};
|
|
47
|
+
publishSessionUpdate(session: Session): Promise<void>;
|
|
48
|
+
/** removeFtownSession closed over its dep bundle. Returns the removed
|
|
49
|
+
* session, or null when nothing was removed (unknown id, or the
|
|
50
|
+
* onlyIfFinished guard rejected it). */
|
|
51
|
+
removeSession(sessionId: string, options?: RemoveFtownSessionOptions): Promise<Session | null>;
|
|
52
|
+
/** Full factory bundle, required by create/retry only. */
|
|
53
|
+
sessionFactory?: CreateFtownSessionDeps;
|
|
54
|
+
/** Optional: extract token/cost usage from harness-native session files.
|
|
55
|
+
* Required only by usage(); omitted in adapters that never call it. */
|
|
56
|
+
collectUsage?(session: Session): Promise<SessionUsage | null>;
|
|
57
|
+
publishSyntheticStop?(sessionId: string, reason: 'complete' | 'error' | 'stopped'): void;
|
|
58
|
+
withSessionWrite?(sessionId: string, task: () => Promise<void>): Promise<void>;
|
|
59
|
+
unregisterSession?(sessionId: string): void;
|
|
60
|
+
flushTerminalBuffer?(sessionId: string): void;
|
|
61
|
+
destroyTerminal?(sessionId: string): void;
|
|
62
|
+
}
|
|
63
|
+
export declare class SessionController {
|
|
64
|
+
private readonly deps;
|
|
65
|
+
constructor(deps: SessionControllerDeps);
|
|
66
|
+
private require;
|
|
67
|
+
/** Create + launch a session. Throws (see class doc) — adapters map errors. */
|
|
68
|
+
create(input: CreateFtownSessionInput): Promise<Session>;
|
|
69
|
+
/** Stop a running session and persist/publish its completed state. */
|
|
70
|
+
stop(sessionId: string): Promise<{
|
|
71
|
+
stopped: boolean;
|
|
72
|
+
}>;
|
|
73
|
+
list(): Promise<Session[]>;
|
|
74
|
+
get(sessionId: string): Promise<Session | null>;
|
|
75
|
+
/**
|
|
76
|
+
* Per-session token/cost usage. Returns the persisted snapshot when
|
|
77
|
+
* present; otherwise collects on demand (covers live sessions and sessions
|
|
78
|
+
* that finished before usage collection existed). An on-demand result is
|
|
79
|
+
* persisted + published only when the session is terminal — a live
|
|
80
|
+
* session's numbers are still moving, so caching them would go stale.
|
|
81
|
+
*/
|
|
82
|
+
usage(sessionId: string): Promise<SessionControllerResult<{
|
|
83
|
+
usage: SessionUsage | null;
|
|
84
|
+
}>>;
|
|
85
|
+
/** Re-run a finished/dead session's stored command verbatim. */
|
|
86
|
+
retry(sessionId: string): Promise<SessionControllerResult<{
|
|
87
|
+
session: Session;
|
|
88
|
+
}>>;
|
|
89
|
+
/**
|
|
90
|
+
* Rename and/or reparent in ONE load-mutate-save-publish pass (the HTTP
|
|
91
|
+
* PATCH route updates both fields atomically; the RPC adapter calls with a
|
|
92
|
+
* single field). Reparenting flattens to the proposed parent's root so
|
|
93
|
+
* session trees stay one level deep.
|
|
94
|
+
*/
|
|
95
|
+
update(sessionId: string, input: SessionUpdateInput): Promise<SessionControllerResult<{
|
|
96
|
+
session: Session;
|
|
97
|
+
}>>;
|
|
98
|
+
/** Remove (archive + delete + publish removed). removed=false when nothing
|
|
99
|
+
* was removed — unknown id, or the onlyIfFinished guard rejected it. */
|
|
100
|
+
remove(sessionId: string, options?: RemoveFtownSessionOptions): Promise<{
|
|
101
|
+
removed: boolean;
|
|
102
|
+
}>;
|
|
103
|
+
/** Flush pending output, wipe the persisted log, drop the live buffer. */
|
|
104
|
+
clearTerminal(sessionId: string): Promise<{
|
|
105
|
+
cleared: true;
|
|
106
|
+
}>;
|
|
107
|
+
}
|