@parall/agent-core 1.18.0 → 1.19.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.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Shared workspace instructions seeded into every self-hosted bridge runtime
3
+ * (Claude Code writes this to `CLAUDE.md`, Codex to `AGENTS.md`). The text and
4
+ * the Parall CLI detection helpers live together so the seeded command
5
+ * examples and the runtime-side suppression logic cannot drift apart. Each
6
+ * bridge package is responsible for its own filesystem write —
7
+ * `@parall/agent-core` stays runtime-neutral and exports only text + pure
8
+ * helpers here.
9
+ */
10
+ export declare const BRIDGE_WORKSPACE_INSTRUCTIONS = "# Agent workspace\n\nYou are an agent in Parall IM. You participate in chats, handle tasks, and interact exclusively through the Parall CLI.\n\n## Message Model\n\nIncoming events are rendered as structured `[Event: ...]` blocks.\nEach event includes `[Chat: ... (prll://cht_xxx)]` \u2014 use that chat ID (or full URI) when replying.\n\n**Your plain-text output is not delivered to anyone** \u2014 it is recorded as suppressed thinking in your session steps and discarded from the chat.\nTo say something in a chat, you **must** invoke the Parall CLI via your shell/exec tool. To stay silent, simply do not invoke it.\n\n## Parall CLI\n\nAll outbound interactions go through `@parall/cli`. Credentials are pre-injected as environment variables \u2014 no setup needed.\n\n- `npx --yes @parall/cli@latest messages send prll://cht_xxx --text \"...\"` \u2014 reply into the triggering chat\n- `npx --yes @parall/cli@latest dm prll://usr_xxx --text \"...\" [--no-reply]` \u2014 direct message another user\n- `npx --yes @parall/cli@latest tasks update prll://tsk_xxx --status in_progress` \u2014 task state\n- `npx --yes @parall/cli@latest no-reply [--reason \"...\"]` \u2014 explicitly declare this turn silent (audit signal; not required for silence, just clarifies intent)\n\nThe bridge injects Parall context via environment variables. The static credentials `PRLL_API_URL`, `PRLL_API_KEY`, and `PRLL_ORG_ID` are always set. Per-dispatch context \u2014 `PRLL_SESSION_ID`, `PRLL_CHAT_ID`, `PRLL_TRIGGER_MESSAGE_ID`, `PRLL_STEP_ID_FILE` \u2014 is set in subprocess-per-dispatch runtimes (Claude Code); in long-running runtimes (Codex) those may be absent and the CLI will fall back to whatever defaults you supply on the command line.\n\n## Guardrails\n\n- A dispatch may coalesce multiple events. Decide per event whether to reply via `messages send` / `dm` \u2014 events you do not act on simply receive no reply.\n- If an event carries `[Hint: no_reply]`, do not send anything for that event. `no-reply` is optional and only useful as an explicit intent marker.\n- Never try to \"speak\" by typing sentences like \"No response needed\" / \"Noted\" / \"OK\" \u2014 they are discarded, so they accomplish nothing except polluting your session log.\n- Keep CLI replies concise and task-focused.\n\nSee `docs/engineering-design/agent-dm-loop-prevention.md` \u00A7 Layer 0 for why plain text is never auto-projected.\n";
11
+ /** Extracts the `command` string from a shell/bash tool call's input payload. */
12
+ export declare function extractShellCommand(input: unknown): string | undefined;
13
+ /**
14
+ * Returns the non-flag tokens that follow a Parall CLI invocation in
15
+ * `command`, or `null` if the command isn't a Parall CLI call.
16
+ *
17
+ * Handles all documented launch forms — bare `parall`, `npx [--yes|-y]
18
+ * @parall/cli[@version]`, `pnpm (exec|dlx) parall` — and skips npx flags so
19
+ * `npx --yes` matches the same way as `npx -y`. Uses space-delimited
20
+ * tokenization rather than a regex so that `messages send --no-reply` is
21
+ * correctly classified as a `messages send` invocation (not the receiver-side
22
+ * `no-reply` subcommand).
23
+ */
24
+ export declare function parseParallCliInvocation(command: string): string[] | null;
25
+ /**
26
+ * Detects whether a shell command invokes the Parall CLI to send a
27
+ * chat-visible side effect (`messages send` or `dm`). Bridge adapters use
28
+ * this to suppress the immediately-following runtime text so the user
29
+ * doesn't see the same message twice — once from the CLI call, once
30
+ * projected from runtime output.
31
+ */
32
+ export declare function isParallSendCommand(command: string | undefined): boolean;
33
+ /**
34
+ * Detects whether a shell command invokes `parall no-reply`, which signals
35
+ * the agent wants the whole turn silenced. Bridge adapters use this to
36
+ * flip a sticky suppression flag for the rest of the dispatch so no text
37
+ * event from that turn is projected into chat.
38
+ */
39
+ export declare function isParallNoReplyCommand(command: string | undefined): boolean;
40
+ //# sourceMappingURL=bridge-workspace.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bridge-workspace.d.ts","sourceRoot":"","sources":["../src/bridge-workspace.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,eAAO,MAAM,6BAA6B,m3EA+BzC,CAAC;AAEF,iFAAiF;AACjF,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAItE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAmBzE;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAKxE;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAI3E"}
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Shared workspace instructions seeded into every self-hosted bridge runtime
3
+ * (Claude Code writes this to `CLAUDE.md`, Codex to `AGENTS.md`). The text and
4
+ * the Parall CLI detection helpers live together so the seeded command
5
+ * examples and the runtime-side suppression logic cannot drift apart. Each
6
+ * bridge package is responsible for its own filesystem write —
7
+ * `@parall/agent-core` stays runtime-neutral and exports only text + pure
8
+ * helpers here.
9
+ */
10
+ export const BRIDGE_WORKSPACE_INSTRUCTIONS = `# Agent workspace
11
+
12
+ You are an agent in Parall IM. You participate in chats, handle tasks, and interact exclusively through the Parall CLI.
13
+
14
+ ## Message Model
15
+
16
+ Incoming events are rendered as structured \`[Event: ...]\` blocks.
17
+ Each event includes \`[Chat: ... (prll://cht_xxx)]\` — use that chat ID (or full URI) when replying.
18
+
19
+ **Your plain-text output is not delivered to anyone** — it is recorded as suppressed thinking in your session steps and discarded from the chat.
20
+ To say something in a chat, you **must** invoke the Parall CLI via your shell/exec tool. To stay silent, simply do not invoke it.
21
+
22
+ ## Parall CLI
23
+
24
+ All outbound interactions go through \`@parall/cli\`. Credentials are pre-injected as environment variables — no setup needed.
25
+
26
+ - \`npx --yes @parall/cli@latest messages send prll://cht_xxx --text "..."\` — reply into the triggering chat
27
+ - \`npx --yes @parall/cli@latest dm prll://usr_xxx --text "..." [--no-reply]\` — direct message another user
28
+ - \`npx --yes @parall/cli@latest tasks update prll://tsk_xxx --status in_progress\` — task state
29
+ - \`npx --yes @parall/cli@latest no-reply [--reason "..."]\` — explicitly declare this turn silent (audit signal; not required for silence, just clarifies intent)
30
+
31
+ The bridge injects Parall context via environment variables. The static credentials \`PRLL_API_URL\`, \`PRLL_API_KEY\`, and \`PRLL_ORG_ID\` are always set. Per-dispatch context — \`PRLL_SESSION_ID\`, \`PRLL_CHAT_ID\`, \`PRLL_TRIGGER_MESSAGE_ID\`, \`PRLL_STEP_ID_FILE\` — is set in subprocess-per-dispatch runtimes (Claude Code); in long-running runtimes (Codex) those may be absent and the CLI will fall back to whatever defaults you supply on the command line.
32
+
33
+ ## Guardrails
34
+
35
+ - A dispatch may coalesce multiple events. Decide per event whether to reply via \`messages send\` / \`dm\` — events you do not act on simply receive no reply.
36
+ - If an event carries \`[Hint: no_reply]\`, do not send anything for that event. \`no-reply\` is optional and only useful as an explicit intent marker.
37
+ - Never try to "speak" by typing sentences like "No response needed" / "Noted" / "OK" — they are discarded, so they accomplish nothing except polluting your session log.
38
+ - Keep CLI replies concise and task-focused.
39
+
40
+ See \`docs/engineering-design/agent-dm-loop-prevention.md\` § Layer 0 for why plain text is never auto-projected.
41
+ `;
42
+ /** Extracts the `command` string from a shell/bash tool call's input payload. */
43
+ export function extractShellCommand(input) {
44
+ if (!input || typeof input !== "object")
45
+ return undefined;
46
+ const command = input.command;
47
+ return typeof command === "string" && command.trim() ? command.trim() : undefined;
48
+ }
49
+ /**
50
+ * Returns the non-flag tokens that follow a Parall CLI invocation in
51
+ * `command`, or `null` if the command isn't a Parall CLI call.
52
+ *
53
+ * Handles all documented launch forms — bare `parall`, `npx [--yes|-y]
54
+ * @parall/cli[@version]`, `pnpm (exec|dlx) parall` — and skips npx flags so
55
+ * `npx --yes` matches the same way as `npx -y`. Uses space-delimited
56
+ * tokenization rather than a regex so that `messages send --no-reply` is
57
+ * correctly classified as a `messages send` invocation (not the receiver-side
58
+ * `no-reply` subcommand).
59
+ */
60
+ export function parseParallCliInvocation(command) {
61
+ const tokens = command.replace(/\s+/g, " ").trim().split(" ");
62
+ let i = 0;
63
+ if (tokens[i] === "parall") {
64
+ i++;
65
+ }
66
+ else if (tokens[i] === "npx") {
67
+ i++;
68
+ while (i < tokens.length && tokens[i].startsWith("-"))
69
+ i++;
70
+ if (i >= tokens.length || !/^@parall\/cli(?:@.+)?$/.test(tokens[i]))
71
+ return null;
72
+ i++;
73
+ }
74
+ else if (tokens[i] === "pnpm") {
75
+ i++;
76
+ if (i < tokens.length && (tokens[i] === "exec" || tokens[i] === "dlx"))
77
+ i++;
78
+ if (i >= tokens.length || tokens[i] !== "parall")
79
+ return null;
80
+ i++;
81
+ }
82
+ else {
83
+ return null;
84
+ }
85
+ return tokens.slice(i).filter((t) => !t.startsWith("-"));
86
+ }
87
+ /**
88
+ * Detects whether a shell command invokes the Parall CLI to send a
89
+ * chat-visible side effect (`messages send` or `dm`). Bridge adapters use
90
+ * this to suppress the immediately-following runtime text so the user
91
+ * doesn't see the same message twice — once from the CLI call, once
92
+ * projected from runtime output.
93
+ */
94
+ export function isParallSendCommand(command) {
95
+ if (!command)
96
+ return false;
97
+ const sub = parseParallCliInvocation(command);
98
+ if (!sub || sub.length === 0)
99
+ return false;
100
+ return sub[0] === "dm" || (sub[0] === "messages" && sub[1] === "send");
101
+ }
102
+ /**
103
+ * Detects whether a shell command invokes `parall no-reply`, which signals
104
+ * the agent wants the whole turn silenced. Bridge adapters use this to
105
+ * flip a sticky suppression flag for the rest of the dispatch so no text
106
+ * event from that turn is projected into chat.
107
+ */
108
+ export function isParallNoReplyCommand(command) {
109
+ if (!command)
110
+ return false;
111
+ const sub = parseParallCliInvocation(command);
112
+ return sub?.[0] === "no-reply";
113
+ }
@@ -18,6 +18,7 @@ export type ParallGatewayOptions = {
18
18
  dispatchAdapter: DispatchAdapter;
19
19
  log?: GatewayLogger;
20
20
  coldStartWindowMs?: number;
21
+ shutdownDeadlineMs?: number;
21
22
  stepIdFilePathForSession?: (sessionKey: string) => string | undefined;
22
23
  onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
23
24
  onSessionReady?: (state: {
@@ -27,6 +28,7 @@ export type ParallGatewayOptions = {
27
28
  }) => Promise<void> | void;
28
29
  onBeforeDisconnect?: () => Promise<void> | void;
29
30
  };
31
+ export declare function parseShutdownDeadlineMs(raw: string | undefined): number | undefined;
30
32
  export declare class ParallAgentGateway {
31
33
  private readonly opts;
32
34
  private readonly chatInfoMap;
@@ -41,8 +43,12 @@ export declare class ParallAgentGateway {
41
43
  private hadSuccessfulHello;
42
44
  private lastHeartbeatAt;
43
45
  private draining;
46
+ private shuttingDown;
47
+ private inFlightDispatches;
48
+ private drainResolvers;
44
49
  private readonly DISPATCHED_MESSAGES_CAP;
45
50
  private readonly COLD_START_WINDOW_MS;
51
+ private readonly SHUTDOWN_DEADLINE_MS;
46
52
  constructor(opts: ParallGatewayOptions);
47
53
  run(abortSignal: AbortSignal): Promise<void>;
48
54
  private tryClaimMessage;
@@ -65,6 +71,7 @@ export declare class ParallAgentGateway {
65
71
  private handleTaskComment;
66
72
  private catchUpFromDispatch;
67
73
  private handleHello;
74
+ private waitForDrain;
68
75
  private shutdown;
69
76
  }
70
77
  //# sourceMappingURL=gateway-base.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"gateway-base.d.ts","sourceRoot":"","sources":["../src/gateway-base.ts"],"names":[],"mappings":"AAIA,OAAO,EAAuB,YAAY,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC1E,OAAO,KAAK,EACV,qBAAqB,EAUtB,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAEV,eAAe,EAGf,aAAa,EAEd,MAAM,uBAAuB,CAAC;AAoD/B,MAAM,MAAM,oBAAoB,GAAG;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,YAAY,CAAC;IACrB,EAAE,EAAE,QAAQ,CAAC;IACb,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,EAAE;QACN,UAAU,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,eAAe,EAAE,eAAe,CAAC;IACjC,GAAG,CAAC,EAAE,aAAa,CAAC;IACpB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,wBAAwB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;IACtE,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,qBAAqB,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACvE,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,eAAe,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,QAAQ,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACjH,kBAAkB,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CACjD,CAAC;AAkCF,qBAAa,kBAAkB;IAuBjB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAtBjC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA+B;IAC3D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqC;IACtE,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAqB;IACrD,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAqB;IACxD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAsC;IACjE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAK5B;IAEF,OAAO,CAAC,SAAS,CAAM;IACvB,OAAO,CAAC,eAAe,CAAqB;IAC5C,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,eAAe,CAAc;IACrC,OAAO,CAAC,QAAQ,CAAS;IAEzB,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAAQ;IAChD,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAS;gBAEjB,IAAI,EAAE,oBAAoB;IAIjD,GAAG,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IA2FlD,OAAO,CAAC,eAAe;IAavB,OAAO,CAAC,WAAW;IAcnB,OAAO,CAAC,UAAU;IAWlB,OAAO,CAAC,oBAAoB;YAmBd,eAAe;YAqBf,iBAAiB;IAsF/B,OAAO,CAAC,eAAe;IASvB,OAAO,CAAC,eAAe;YAQT,gCAAgC;YAMhC,WAAW;YAyDX,gBAAgB;YAmEhB,eAAe;YAmDf,kBAAkB;YAoElB,kBAAkB;YAkBlB,4BAA4B;YAoD5B,aAAa;YA8Bb,oBAAoB;YAmCpB,iBAAiB;YAqEjB,mBAAmB;YAsGnB,WAAW;YA0DX,QAAQ;CAsBvB"}
1
+ {"version":3,"file":"gateway-base.d.ts","sourceRoot":"","sources":["../src/gateway-base.ts"],"names":[],"mappings":"AAIA,OAAO,EAAuB,YAAY,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC1E,OAAO,KAAK,EACV,qBAAqB,EAUtB,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAEV,eAAe,EAGf,aAAa,EAEd,MAAM,uBAAuB,CAAC;AAoD/B,MAAM,MAAM,oBAAoB,GAAG;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,YAAY,CAAC;IACrB,EAAE,EAAE,QAAQ,CAAC;IACb,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,MAAM,EAAE;QACN,UAAU,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,eAAe,EAAE,eAAe,CAAC;IACjC,GAAG,CAAC,EAAE,aAAa,CAAC;IACpB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAI3B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,wBAAwB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;IACtE,cAAc,CAAC,EAAE,CAAC,IAAI,EAAE,qBAAqB,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACvE,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,eAAe,CAAC,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,QAAQ,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACjH,kBAAkB,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CACjD,CAAC;AAUF,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAKnF;AAkCD,qBAAa,kBAAkB;IAmCjB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAlCjC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA+B;IAC3D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqC;IACtE,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAqB;IACrD,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAqB;IACxD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAsC;IACjE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAK5B;IAEF,OAAO,CAAC,SAAS,CAAM;IACvB,OAAO,CAAC,eAAe,CAAqB;IAC5C,OAAO,CAAC,cAAc,CAA+C;IACrE,OAAO,CAAC,kBAAkB,CAAS;IACnC,OAAO,CAAC,eAAe,CAAc;IACrC,OAAO,CAAC,QAAQ,CAAS;IAMzB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,kBAAkB,CAAK;IAC/B,OAAO,CAAC,cAAc,CAAyB;IAE/C,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAAQ;IAChD,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAS;IAI9C,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAS;gBAEjB,IAAI,EAAE,oBAAoB;IAKjD,GAAG,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IA2FlD,OAAO,CAAC,eAAe;IAavB,OAAO,CAAC,WAAW;IAcnB,OAAO,CAAC,UAAU;IAWlB,OAAO,CAAC,oBAAoB;YAmBd,eAAe;YAqBf,iBAAiB;IAsF/B,OAAO,CAAC,eAAe;IASvB,OAAO,CAAC,eAAe;YAQT,gCAAgC;YAUhC,WAAW;YAoFX,gBAAgB;YAkFhB,eAAe;YA6Ef,kBAAkB;YA2ElB,kBAAkB;YAkBlB,4BAA4B;YAoD5B,aAAa;YA+Bb,oBAAoB;YAoCpB,iBAAiB;YAsEjB,mBAAmB;YA2GnB,WAAW;IA6DzB,OAAO,CAAC,YAAY;YAiBN,QAAQ;CA4CvB"}
@@ -6,6 +6,22 @@ import { MENTION_ALL_USER_ID } from "@parall/sdk";
6
6
  import { buildEventBody, buildForkResultPrefix } from "./event-format.js";
7
7
  import { routeTrigger } from "./routing.js";
8
8
  import { clearDispatchMessageId, clearDispatchNoReply, clearSessionMessageId, setDispatchMessageId, setDispatchNoReply, setSessionChatId, setSessionMessageId, } from "./session-state.js";
9
+ // Parse PRLL_SHUTDOWN_DEADLINE_MS (or any string env value) into a positive
10
+ // integer milliseconds value, or undefined if unset/invalid. Runtimes pass
11
+ // the result into ParallGatewayOptions.shutdownDeadlineMs; leaving it
12
+ // undefined falls back to the gateway default (60s). Operators can raise it
13
+ // when long-running tools need more time to drain — the K8s
14
+ // terminationGracePeriodSeconds (currently 90s) must remain larger than this
15
+ // plus the post-drain cleanup window (~15s), or the kubelet will SIGKILL
16
+ // before cleanup finishes.
17
+ export function parseShutdownDeadlineMs(raw) {
18
+ if (!raw)
19
+ return undefined;
20
+ const n = Number(raw);
21
+ if (!Number.isFinite(n) || n <= 0)
22
+ return undefined;
23
+ return Math.floor(n);
24
+ }
9
25
  function resolveStepTarget(event) {
10
26
  if (event.type === "task" || event.targetId.startsWith("tsk_")) {
11
27
  return { target_type: "task", target_id: event.targetId };
@@ -51,11 +67,23 @@ export class ParallAgentGateway {
51
67
  hadSuccessfulHello = false;
52
68
  lastHeartbeatAt = Date.now();
53
69
  draining = false;
70
+ // Graceful shutdown state. When SIGTERM / abort fires, `shuttingDown` flips
71
+ // to true so no new dispatches start, and `inFlightDispatches` counts runs
72
+ // still in progress. `shutdown()` awaits drain up to SHUTDOWN_DEADLINE_MS
73
+ // before tearing down the WS; see handleTermination caller.
74
+ shuttingDown = false;
75
+ inFlightDispatches = 0;
76
+ drainResolvers = [];
54
77
  DISPATCHED_MESSAGES_CAP = 5000;
55
78
  COLD_START_WINDOW_MS;
79
+ // SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
80
+ // below — kept as instance state so per-runtime configs can override it
81
+ // (see parseShutdownDeadlineMs and runtime entrypoints).
82
+ SHUTDOWN_DEADLINE_MS;
56
83
  constructor(opts) {
57
84
  this.opts = opts;
58
85
  this.COLD_START_WINDOW_MS = opts.coldStartWindowMs ?? 5 * 60_000;
86
+ this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
59
87
  }
60
88
  async run(abortSignal) {
61
89
  const { ws, log } = this.opts;
@@ -329,13 +357,28 @@ export class ParallAgentGateway {
329
357
  await this.createInputStep(event);
330
358
  }
331
359
  }
360
+ // Returns true if the dispatch actually ran; false if skipped because we
361
+ // are shutting down. Callers MUST treat `false` as "not dispatched" and
362
+ // skip the server-side ack so the event stays in the dispatch queue for
363
+ // catch-up on the replacement pod — otherwise we silently drop work.
332
364
  async runDispatch(event, sessionKey, bodyForAgent, earlierEvents = []) {
365
+ // Graceful shutdown: once SIGTERM has fired we stop accepting new work.
366
+ // In-flight dispatches that started before the flag flipped keep running
367
+ // and are awaited by shutdown() up to SHUTDOWN_DEADLINE_MS.
368
+ if (this.shuttingDown) {
369
+ this.opts.log?.info(`parall[${this.opts.accountId}]: skipping dispatch for ${event.messageId} (shutting down) — leaving unacked for catch-up on replacement pod`);
370
+ return false;
371
+ }
333
372
  setSessionChatId(sessionKey, event.targetId);
334
373
  setSessionMessageId(sessionKey, event.messageId);
335
374
  setDispatchMessageId(sessionKey, event.messageId);
336
375
  setDispatchNoReply(sessionKey, event.noReply ?? false);
337
376
  const dispatchContext = this.buildDispatchContext(event, sessionKey);
338
377
  const stepIdFilePath = dispatchContext.stepIdFilePath;
378
+ // sync: no await between the shuttingDown check above and this increment
379
+ // — JS event loop is single-threaded, so shutdown() cannot interleave
380
+ // here and miss our in-flight count.
381
+ this.inFlightDispatches++;
339
382
  try {
340
383
  if (this.activeSessionId) {
341
384
  try {
@@ -345,6 +388,13 @@ export class ParallAgentGateway {
345
388
  this.opts.log?.warn(`parall[${this.opts.accountId}]: failed to set session active: ${String(err)}`);
346
389
  }
347
390
  }
391
+ // Persist input steps for "earlier events" (batched events that arrived
392
+ // while a dispatch was in flight) inside the in-flight window so a
393
+ // shutdown short-circuit BEFORE this point cannot leave orphan input
394
+ // steps that the replacement pod would duplicate on replay.
395
+ if (earlierEvents.length > 0) {
396
+ await this.createInputStepsForEarlierEvents(earlierEvents);
397
+ }
348
398
  await this.createInputStep(event);
349
399
  for await (const runtimeEvent of this.opts.dispatchAdapter.dispatch({
350
400
  event,
@@ -378,20 +428,43 @@ export class ParallAgentGateway {
378
428
  this.opts.log?.warn(`parall[${this.opts.accountId}]: failed to set session idle: ${String(err)}`);
379
429
  }
380
430
  }
431
+ this.inFlightDispatches--;
432
+ if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
433
+ const resolvers = this.drainResolvers.splice(0);
434
+ for (const resolve of resolvers)
435
+ resolve();
436
+ }
381
437
  }
438
+ return true;
382
439
  }
383
440
  async runForkDrainLoop(fork) {
384
441
  try {
385
442
  while (fork.queue.length > 0) {
443
+ // Early shutdown guard — pure optimization to avoid wasted draining
444
+ // during the shutdown window. The structural correctness now lives in
445
+ // runDispatch: input AgentStep persistence happens behind its
446
+ // shuttingDown gate and inFlightDispatches counter, so a shutdown
447
+ // landing mid-batch cannot leave orphan steps that replay would
448
+ // duplicate.
449
+ if (this.shuttingDown) {
450
+ for (const item of fork.queue.splice(0))
451
+ item.resolve(false);
452
+ break;
453
+ }
386
454
  const items = fork.queue.splice(0);
387
455
  const events = items.map((item) => item.event);
388
456
  const last = events[events.length - 1];
389
457
  const earlier = events.slice(0, -1);
390
458
  try {
391
- if (earlier.length > 0) {
392
- await this.createInputStepsForEarlierEvents(earlier);
459
+ const dispatched = await this.runDispatch(last, fork.fork.sessionKey, buildEventBody(last), earlier);
460
+ if (!dispatched) {
461
+ // Shutdown short-circuit — resolve un-acked so the server requeues
462
+ // for the replacement pod and stop draining further items.
463
+ for (const item of items) {
464
+ item.resolve(false);
465
+ }
466
+ break;
393
467
  }
394
- await this.runDispatch(last, fork.fork.sessionKey, buildEventBody(last), earlier);
395
468
  fork.processedEvents.push(...events);
396
469
  for (const item of items) {
397
470
  item.resolve(true);
@@ -453,8 +526,17 @@ export class ParallAgentGateway {
453
526
  this.draining = true;
454
527
  try {
455
528
  while (this.dispatchState.mainBuffer.length > 0 || this.dispatchState.pendingForkResults.length > 0) {
529
+ // Early shutdown guard — same reasoning as runForkDrainLoop: stop
530
+ // before any input AgentSteps are persisted, leaving the buffer +
531
+ // pendingForkResults intact so dispatch catch-up on the replacement
532
+ // pod is the single source of truth for replay.
533
+ if (this.shuttingDown) {
534
+ this.opts.log?.info(`parall[${this.opts.accountId}]: drainMainBuffer halted (shutting down) — ${this.dispatchState.mainBuffer.length} buffered, ${this.dispatchState.pendingForkResults.length} pending fork results left for catch-up`);
535
+ break;
536
+ }
456
537
  if (this.dispatchState.mainBuffer.length === 0 && this.dispatchState.pendingForkResults.length > 0) {
457
- const forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
538
+ const pending = this.dispatchState.pendingForkResults.splice(0);
539
+ const forkPrefix = buildForkResultPrefix(pending);
458
540
  const syntheticEvent = {
459
541
  type: "message",
460
542
  targetId: "_orchestrator",
@@ -465,7 +547,14 @@ export class ParallAgentGateway {
465
547
  body: "[Orchestrator: fork session(s) completed — review results above]",
466
548
  };
467
549
  this.dispatchState.mainCurrentTargetId = undefined;
468
- await this.runDispatch(syntheticEvent, this.opts.runtimeKey, forkPrefix + buildEventBody(syntheticEvent));
550
+ const dispatched = await this.runDispatch(syntheticEvent, this.opts.runtimeKey, forkPrefix + buildEventBody(syntheticEvent));
551
+ if (!dispatched) {
552
+ // Shutdown: put the fork results back at the head so the synthetic
553
+ // event is regenerated on the replacement pod. Synthetic events are
554
+ // not ack'd, so this is the only state we need to preserve.
555
+ this.dispatchState.pendingForkResults.unshift(...pending);
556
+ break;
557
+ }
469
558
  continue;
470
559
  }
471
560
  const targetId = this.dispatchState.mainBuffer[0].targetId;
@@ -475,12 +564,20 @@ export class ParallAgentGateway {
475
564
  }
476
565
  const event = events[events.length - 1];
477
566
  const earlier = events.slice(0, -1);
478
- const forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
567
+ const pendingFork = this.dispatchState.pendingForkResults.splice(0);
568
+ const forkPrefix = buildForkResultPrefix(pendingFork);
479
569
  this.dispatchState.mainCurrentTargetId = event.targetId;
480
- if (earlier.length > 0) {
481
- await this.createInputStepsForEarlierEvents(earlier);
570
+ // Earlier-event input steps are persisted inside runDispatch (behind
571
+ // its shutdown gate) so a shutdown short-circuit cannot leave orphans.
572
+ const dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event), earlier);
573
+ if (!dispatched) {
574
+ // Shutdown: skip the ack so the server redelivers these buffered
575
+ // events to the replacement pod via dispatch catch-up. Put both the
576
+ // buffered events and the fork results back so nothing is lost.
577
+ this.dispatchState.mainBuffer.unshift(...events);
578
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
579
+ break;
482
580
  }
483
- await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event), earlier);
484
581
  for (const bufferedEvent of events) {
485
582
  const sourceType = bufferedEvent.ackSourceType ?? (bufferedEvent.type === "task" ? "task_activity" : "message");
486
583
  const sourceId = bufferedEvent.ackSourceId ?? bufferedEvent.messageId;
@@ -501,16 +598,23 @@ export class ParallAgentGateway {
501
598
  const disposition = routeTrigger(event, this.dispatchState);
502
599
  switch (disposition.action) {
503
600
  case "main": {
504
- const forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
601
+ const pendingFork = this.dispatchState.pendingForkResults.splice(0);
602
+ const forkPrefix = buildForkResultPrefix(pendingFork);
505
603
  this.dispatchState.mainDispatching = true;
506
604
  this.dispatchState.mainCurrentTargetId = event.targetId;
605
+ let dispatched = false;
507
606
  try {
508
- await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
607
+ dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
608
+ if (!dispatched) {
609
+ // Shutdown short-circuit — restore the fork results so a future
610
+ // pod can replay them, and return false so handleMessage skips ack.
611
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
612
+ }
509
613
  }
510
614
  finally {
511
615
  await this.drainMainBuffer();
512
616
  }
513
- return true;
617
+ return dispatched;
514
618
  }
515
619
  case "buffer-main":
516
620
  this.dispatchState.mainBuffer.push(event);
@@ -620,6 +724,8 @@ export class ParallAgentGateway {
620
724
  };
621
725
  }
622
726
  async handleMessage(data) {
727
+ if (this.shuttingDown)
728
+ return; // drain window — let server requeue via catch-up
623
729
  const chatId = data.chat_id;
624
730
  if (data.sender_id === this.opts.agentUserId)
625
731
  return;
@@ -655,6 +761,8 @@ export class ParallAgentGateway {
655
761
  }
656
762
  }
657
763
  async handleTaskAssignment(task, ackSourceId) {
764
+ if (this.shuttingDown)
765
+ return false; // drain window — let server requeue via catch-up
658
766
  const dedupeKey = `${task.id}:${task.updated_at}`;
659
767
  if (this.dispatchedTasks.has(dedupeKey)) {
660
768
  this.opts.log?.info(`parall[${this.opts.accountId}]: skipping already-dispatched task ${task.identifier ?? task.id}`);
@@ -689,6 +797,8 @@ export class ParallAgentGateway {
689
797
  return dispatched;
690
798
  }
691
799
  async handleTaskComment(commentId, taskId, actorId) {
800
+ if (this.shuttingDown)
801
+ return false; // drain window — let server requeue via catch-up
692
802
  const dedupeKey = `comment:${commentId}`;
693
803
  if (this.dispatchedTasks.has(dedupeKey))
694
804
  return false;
@@ -766,6 +876,10 @@ export class ParallAgentGateway {
766
876
  cursor,
767
877
  });
768
878
  for (const item of page.data ?? []) {
879
+ // Shutdown short-circuit: stop fetching/building work that runDispatch
880
+ // will only reject. Items remain unacked for the replacement pod.
881
+ if (this.shuttingDown)
882
+ break;
769
883
  if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
770
884
  this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => { });
771
885
  skippedOld++;
@@ -854,7 +968,9 @@ export class ParallAgentGateway {
854
968
  this.opts.log?.warn(`parall[${this.opts.accountId}]: catch-up dispatch ${item.id} (${item.event_type}) failed: ${String(err)}`);
855
969
  }
856
970
  }
857
- cursor = page.has_more ? page.next_cursor : undefined;
971
+ // Stop paginating once shutdown begins — the inner loop already broke,
972
+ // and the next page would be wasted API work the replacement pod redoes.
973
+ cursor = !this.shuttingDown && page.has_more ? page.next_cursor : undefined;
858
974
  } while (cursor);
859
975
  if (processed > 0 || skippedOld > 0) {
860
976
  this.opts.log?.info(`parall[${this.opts.accountId}]: dispatch catch-up: processed ${processed}, skipped ${skippedOld} old item(s)`);
@@ -917,7 +1033,45 @@ export class ParallAgentGateway {
917
1033
  log?.error(`parall[${this.opts.accountId}]: failed to fetch chats: ${String(err)}`);
918
1034
  }
919
1035
  }
1036
+ // Resolves when in-flight dispatches hit 0 or the deadline elapses.
1037
+ // Deadline arming the resolver rather than rejecting keeps shutdown() linear
1038
+ // — callers don't need to try/catch.
1039
+ waitForDrain(deadlineMs) {
1040
+ if (this.inFlightDispatches === 0)
1041
+ return Promise.resolve();
1042
+ return new Promise((resolve) => {
1043
+ const timer = setTimeout(() => {
1044
+ // Remove this resolver so a late-completing dispatch doesn't call it.
1045
+ const idx = this.drainResolvers.indexOf(onDrain);
1046
+ if (idx >= 0)
1047
+ this.drainResolvers.splice(idx, 1);
1048
+ resolve();
1049
+ }, deadlineMs);
1050
+ const onDrain = () => {
1051
+ clearTimeout(timer);
1052
+ resolve();
1053
+ };
1054
+ this.drainResolvers.push(onDrain);
1055
+ });
1056
+ }
920
1057
  async shutdown() {
1058
+ // Flip the flag first so any WS event that fires while we're draining
1059
+ // short-circuits at runDispatch instead of starting new work.
1060
+ this.shuttingDown = true;
1061
+ // Drain: wait for in-flight dispatches to finish up to the configured
1062
+ // deadline. If we time out, whatever is still running will be killed by
1063
+ // the process exit — that's acceptable because K8s will retry the event
1064
+ // via dispatch catch-up after the new pod starts.
1065
+ if (this.inFlightDispatches > 0) {
1066
+ this.opts.log?.info(`parall[${this.opts.accountId}]: draining ${this.inFlightDispatches} in-flight dispatch(es), deadline ${this.SHUTDOWN_DEADLINE_MS}ms`);
1067
+ await this.waitForDrain(this.SHUTDOWN_DEADLINE_MS);
1068
+ if (this.inFlightDispatches > 0) {
1069
+ this.opts.log?.warn(`parall[${this.opts.accountId}]: drain deadline hit; ${this.inFlightDispatches} dispatch(es) still running — they will be killed by process exit`);
1070
+ }
1071
+ else {
1072
+ this.opts.log?.info(`parall[${this.opts.accountId}]: drain complete`);
1073
+ }
1074
+ }
921
1075
  if (this.heartbeatTimer)
922
1076
  clearInterval(this.heartbeatTimer);
923
1077
  for (const [, dispatch] of this.activeDispatches) {
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export * from "./session-state.js";
3
3
  export * from "./routing.js";
4
4
  export * from "./event-format.js";
5
5
  export * from "./prompt-fragments.js";
6
+ export * from "./bridge-workspace.js";
6
7
  export * from "./dispatch-adapter.js";
7
8
  export * from "./gateway-base.js";
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,oBAAoB,CAAC;AACnC,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,oBAAoB,CAAC;AACnC,cAAc,cAAc,CAAC;AAC7B,cAAc,mBAAmB,CAAC;AAClC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,mBAAmB,CAAC"}
package/dist/index.js CHANGED
@@ -3,5 +3,6 @@ export * from "./session-state.js";
3
3
  export * from "./routing.js";
4
4
  export * from "./event-format.js";
5
5
  export * from "./prompt-fragments.js";
6
+ export * from "./bridge-workspace.js";
6
7
  export * from "./dispatch-adapter.js";
7
8
  export * from "./gateway-base.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/agent-core",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "description": "Shared agent runtime orchestration helpers for Parall",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,7 +22,7 @@
22
22
  "src"
23
23
  ],
24
24
  "dependencies": {
25
- "@parall/sdk": "1.18.0"
25
+ "@parall/sdk": "1.19.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.0.0",
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Shared workspace instructions seeded into every self-hosted bridge runtime
3
+ * (Claude Code writes this to `CLAUDE.md`, Codex to `AGENTS.md`). The text and
4
+ * the Parall CLI detection helpers live together so the seeded command
5
+ * examples and the runtime-side suppression logic cannot drift apart. Each
6
+ * bridge package is responsible for its own filesystem write —
7
+ * `@parall/agent-core` stays runtime-neutral and exports only text + pure
8
+ * helpers here.
9
+ */
10
+
11
+ export const BRIDGE_WORKSPACE_INSTRUCTIONS = `# Agent workspace
12
+
13
+ You are an agent in Parall IM. You participate in chats, handle tasks, and interact exclusively through the Parall CLI.
14
+
15
+ ## Message Model
16
+
17
+ Incoming events are rendered as structured \`[Event: ...]\` blocks.
18
+ Each event includes \`[Chat: ... (prll://cht_xxx)]\` — use that chat ID (or full URI) when replying.
19
+
20
+ **Your plain-text output is not delivered to anyone** — it is recorded as suppressed thinking in your session steps and discarded from the chat.
21
+ To say something in a chat, you **must** invoke the Parall CLI via your shell/exec tool. To stay silent, simply do not invoke it.
22
+
23
+ ## Parall CLI
24
+
25
+ All outbound interactions go through \`@parall/cli\`. Credentials are pre-injected as environment variables — no setup needed.
26
+
27
+ - \`npx --yes @parall/cli@latest messages send prll://cht_xxx --text "..."\` — reply into the triggering chat
28
+ - \`npx --yes @parall/cli@latest dm prll://usr_xxx --text "..." [--no-reply]\` — direct message another user
29
+ - \`npx --yes @parall/cli@latest tasks update prll://tsk_xxx --status in_progress\` — task state
30
+ - \`npx --yes @parall/cli@latest no-reply [--reason "..."]\` — explicitly declare this turn silent (audit signal; not required for silence, just clarifies intent)
31
+
32
+ The bridge injects Parall context via environment variables. The static credentials \`PRLL_API_URL\`, \`PRLL_API_KEY\`, and \`PRLL_ORG_ID\` are always set. Per-dispatch context — \`PRLL_SESSION_ID\`, \`PRLL_CHAT_ID\`, \`PRLL_TRIGGER_MESSAGE_ID\`, \`PRLL_STEP_ID_FILE\` — is set in subprocess-per-dispatch runtimes (Claude Code); in long-running runtimes (Codex) those may be absent and the CLI will fall back to whatever defaults you supply on the command line.
33
+
34
+ ## Guardrails
35
+
36
+ - A dispatch may coalesce multiple events. Decide per event whether to reply via \`messages send\` / \`dm\` — events you do not act on simply receive no reply.
37
+ - If an event carries \`[Hint: no_reply]\`, do not send anything for that event. \`no-reply\` is optional and only useful as an explicit intent marker.
38
+ - Never try to "speak" by typing sentences like "No response needed" / "Noted" / "OK" — they are discarded, so they accomplish nothing except polluting your session log.
39
+ - Keep CLI replies concise and task-focused.
40
+
41
+ See \`docs/engineering-design/agent-dm-loop-prevention.md\` § Layer 0 for why plain text is never auto-projected.
42
+ `;
43
+
44
+ /** Extracts the `command` string from a shell/bash tool call's input payload. */
45
+ export function extractShellCommand(input: unknown): string | undefined {
46
+ if (!input || typeof input !== "object") return undefined;
47
+ const command = (input as { command?: unknown }).command;
48
+ return typeof command === "string" && command.trim() ? command.trim() : undefined;
49
+ }
50
+
51
+ /**
52
+ * Returns the non-flag tokens that follow a Parall CLI invocation in
53
+ * `command`, or `null` if the command isn't a Parall CLI call.
54
+ *
55
+ * Handles all documented launch forms — bare `parall`, `npx [--yes|-y]
56
+ * @parall/cli[@version]`, `pnpm (exec|dlx) parall` — and skips npx flags so
57
+ * `npx --yes` matches the same way as `npx -y`. Uses space-delimited
58
+ * tokenization rather than a regex so that `messages send --no-reply` is
59
+ * correctly classified as a `messages send` invocation (not the receiver-side
60
+ * `no-reply` subcommand).
61
+ */
62
+ export function parseParallCliInvocation(command: string): string[] | null {
63
+ const tokens = command.replace(/\s+/g, " ").trim().split(" ");
64
+ let i = 0;
65
+ if (tokens[i] === "parall") {
66
+ i++;
67
+ } else if (tokens[i] === "npx") {
68
+ i++;
69
+ while (i < tokens.length && tokens[i].startsWith("-")) i++;
70
+ if (i >= tokens.length || !/^@parall\/cli(?:@.+)?$/.test(tokens[i])) return null;
71
+ i++;
72
+ } else if (tokens[i] === "pnpm") {
73
+ i++;
74
+ if (i < tokens.length && (tokens[i] === "exec" || tokens[i] === "dlx")) i++;
75
+ if (i >= tokens.length || tokens[i] !== "parall") return null;
76
+ i++;
77
+ } else {
78
+ return null;
79
+ }
80
+ return tokens.slice(i).filter((t) => !t.startsWith("-"));
81
+ }
82
+
83
+ /**
84
+ * Detects whether a shell command invokes the Parall CLI to send a
85
+ * chat-visible side effect (`messages send` or `dm`). Bridge adapters use
86
+ * this to suppress the immediately-following runtime text so the user
87
+ * doesn't see the same message twice — once from the CLI call, once
88
+ * projected from runtime output.
89
+ */
90
+ export function isParallSendCommand(command: string | undefined): boolean {
91
+ if (!command) return false;
92
+ const sub = parseParallCliInvocation(command);
93
+ if (!sub || sub.length === 0) return false;
94
+ return sub[0] === "dm" || (sub[0] === "messages" && sub[1] === "send");
95
+ }
96
+
97
+ /**
98
+ * Detects whether a shell command invokes `parall no-reply`, which signals
99
+ * the agent wants the whole turn silenced. Bridge adapters use this to
100
+ * flip a sticky suppression flag for the rest of the dispatch so no text
101
+ * event from that turn is projected into chat.
102
+ */
103
+ export function isParallNoReplyCommand(command: string | undefined): boolean {
104
+ if (!command) return false;
105
+ const sub = parseParallCliInvocation(command);
106
+ return sub?.[0] === "no-reply";
107
+ }
@@ -92,12 +92,31 @@ export type ParallGatewayOptions = {
92
92
  dispatchAdapter: DispatchAdapter;
93
93
  log?: GatewayLogger;
94
94
  coldStartWindowMs?: number;
95
+ // Maximum time to wait for in-flight dispatches to finish after SIGTERM /
96
+ // abort before forcing WS disconnect. Pod termination grace period should
97
+ // be at least this + a few seconds for the remaining cleanup work.
98
+ shutdownDeadlineMs?: number;
95
99
  stepIdFilePathForSession?: (sessionKey: string) => string | undefined;
96
100
  onConfigUpdate?: (data: AgentConfigUpdateData) => Promise<void> | void;
97
101
  onSessionReady?: (state: { activeSessionId?: string; ws: ParallWs; runtimeKey: string }) => Promise<void> | void;
98
102
  onBeforeDisconnect?: () => Promise<void> | void;
99
103
  };
100
104
 
105
+ // Parse PRLL_SHUTDOWN_DEADLINE_MS (or any string env value) into a positive
106
+ // integer milliseconds value, or undefined if unset/invalid. Runtimes pass
107
+ // the result into ParallGatewayOptions.shutdownDeadlineMs; leaving it
108
+ // undefined falls back to the gateway default (60s). Operators can raise it
109
+ // when long-running tools need more time to drain — the K8s
110
+ // terminationGracePeriodSeconds (currently 90s) must remain larger than this
111
+ // plus the post-drain cleanup window (~15s), or the kubelet will SIGKILL
112
+ // before cleanup finishes.
113
+ export function parseShutdownDeadlineMs(raw: string | undefined): number | undefined {
114
+ if (!raw) return undefined;
115
+ const n = Number(raw);
116
+ if (!Number.isFinite(n) || n <= 0) return undefined;
117
+ return Math.floor(n);
118
+ }
119
+
101
120
  function resolveStepTarget(event: ParallEvent): { target_type: string; target_id?: string } {
102
121
  if (event.type === "task" || event.targetId.startsWith("tsk_")) {
103
122
  return { target_type: "task", target_id: event.targetId };
@@ -150,11 +169,24 @@ export class ParallAgentGateway {
150
169
  private lastHeartbeatAt = Date.now();
151
170
  private draining = false;
152
171
 
172
+ // Graceful shutdown state. When SIGTERM / abort fires, `shuttingDown` flips
173
+ // to true so no new dispatches start, and `inFlightDispatches` counts runs
174
+ // still in progress. `shutdown()` awaits drain up to SHUTDOWN_DEADLINE_MS
175
+ // before tearing down the WS; see handleTermination caller.
176
+ private shuttingDown = false;
177
+ private inFlightDispatches = 0;
178
+ private drainResolvers: Array<() => void> = [];
179
+
153
180
  private readonly DISPATCHED_MESSAGES_CAP = 5000;
154
181
  private readonly COLD_START_WINDOW_MS: number;
182
+ // SHUTDOWN_DEADLINE_MS is read by waitForDrain via the configured value
183
+ // below — kept as instance state so per-runtime configs can override it
184
+ // (see parseShutdownDeadlineMs and runtime entrypoints).
185
+ private readonly SHUTDOWN_DEADLINE_MS: number;
155
186
 
156
187
  constructor(private readonly opts: ParallGatewayOptions) {
157
188
  this.COLD_START_WINDOW_MS = opts.coldStartWindowMs ?? 5 * 60_000;
189
+ this.SHUTDOWN_DEADLINE_MS = opts.shutdownDeadlineMs ?? 60_000;
158
190
  }
159
191
 
160
192
  async run(abortSignal: AbortSignal): Promise<void> {
@@ -435,12 +467,26 @@ export class ParallAgentGateway {
435
467
  }
436
468
  }
437
469
 
470
+ // Returns true if the dispatch actually ran; false if skipped because we
471
+ // are shutting down. Callers MUST treat `false` as "not dispatched" and
472
+ // skip the server-side ack so the event stays in the dispatch queue for
473
+ // catch-up on the replacement pod — otherwise we silently drop work.
438
474
  private async runDispatch(
439
475
  event: ParallEvent,
440
476
  sessionKey: string,
441
477
  bodyForAgent: string,
442
478
  earlierEvents: ParallEvent[] = [],
443
- ) {
479
+ ): Promise<boolean> {
480
+ // Graceful shutdown: once SIGTERM has fired we stop accepting new work.
481
+ // In-flight dispatches that started before the flag flipped keep running
482
+ // and are awaited by shutdown() up to SHUTDOWN_DEADLINE_MS.
483
+ if (this.shuttingDown) {
484
+ this.opts.log?.info(
485
+ `parall[${this.opts.accountId}]: skipping dispatch for ${event.messageId} (shutting down) — leaving unacked for catch-up on replacement pod`,
486
+ );
487
+ return false;
488
+ }
489
+
444
490
  setSessionChatId(sessionKey, event.targetId);
445
491
  setSessionMessageId(sessionKey, event.messageId);
446
492
  setDispatchMessageId(sessionKey, event.messageId);
@@ -449,6 +495,10 @@ export class ParallAgentGateway {
449
495
  const dispatchContext = this.buildDispatchContext(event, sessionKey);
450
496
  const stepIdFilePath = dispatchContext.stepIdFilePath;
451
497
 
498
+ // sync: no await between the shuttingDown check above and this increment
499
+ // — JS event loop is single-threaded, so shutdown() cannot interleave
500
+ // here and miss our in-flight count.
501
+ this.inFlightDispatches++;
452
502
  try {
453
503
  if (this.activeSessionId) {
454
504
  try {
@@ -458,6 +508,13 @@ export class ParallAgentGateway {
458
508
  }
459
509
  }
460
510
 
511
+ // Persist input steps for "earlier events" (batched events that arrived
512
+ // while a dispatch was in flight) inside the in-flight window so a
513
+ // shutdown short-circuit BEFORE this point cannot leave orphan input
514
+ // steps that the replacement pod would duplicate on replay.
515
+ if (earlierEvents.length > 0) {
516
+ await this.createInputStepsForEarlierEvents(earlierEvents);
517
+ }
461
518
  await this.createInputStep(event);
462
519
 
463
520
  for await (const runtimeEvent of this.opts.dispatchAdapter.dispatch({
@@ -489,21 +546,42 @@ export class ParallAgentGateway {
489
546
  this.opts.log?.warn(`parall[${this.opts.accountId}]: failed to set session idle: ${String(err)}`);
490
547
  }
491
548
  }
549
+ this.inFlightDispatches--;
550
+ if (this.inFlightDispatches === 0 && this.drainResolvers.length > 0) {
551
+ const resolvers = this.drainResolvers.splice(0);
552
+ for (const resolve of resolvers) resolve();
553
+ }
492
554
  }
555
+ return true;
493
556
  }
494
557
 
495
558
  private async runForkDrainLoop(fork: ActiveForkState) {
496
559
  try {
497
560
  while (fork.queue.length > 0) {
561
+ // Early shutdown guard — pure optimization to avoid wasted draining
562
+ // during the shutdown window. The structural correctness now lives in
563
+ // runDispatch: input AgentStep persistence happens behind its
564
+ // shuttingDown gate and inFlightDispatches counter, so a shutdown
565
+ // landing mid-batch cannot leave orphan steps that replay would
566
+ // duplicate.
567
+ if (this.shuttingDown) {
568
+ for (const item of fork.queue.splice(0)) item.resolve(false);
569
+ break;
570
+ }
498
571
  const items = fork.queue.splice(0);
499
572
  const events = items.map((item) => item.event);
500
573
  const last = events[events.length - 1];
501
574
  const earlier = events.slice(0, -1);
502
575
  try {
503
- if (earlier.length > 0) {
504
- await this.createInputStepsForEarlierEvents(earlier);
576
+ const dispatched = await this.runDispatch(last, fork.fork.sessionKey, buildEventBody(last), earlier);
577
+ if (!dispatched) {
578
+ // Shutdown short-circuit — resolve un-acked so the server requeues
579
+ // for the replacement pod and stop draining further items.
580
+ for (const item of items) {
581
+ item.resolve(false);
582
+ }
583
+ break;
505
584
  }
506
- await this.runDispatch(last, fork.fork.sessionKey, buildEventBody(last), earlier);
507
585
  fork.processedEvents.push(...events);
508
586
  for (const item of items) {
509
587
  item.resolve(true);
@@ -564,8 +642,19 @@ export class ParallAgentGateway {
564
642
  this.draining = true;
565
643
  try {
566
644
  while (this.dispatchState.mainBuffer.length > 0 || this.dispatchState.pendingForkResults.length > 0) {
645
+ // Early shutdown guard — same reasoning as runForkDrainLoop: stop
646
+ // before any input AgentSteps are persisted, leaving the buffer +
647
+ // pendingForkResults intact so dispatch catch-up on the replacement
648
+ // pod is the single source of truth for replay.
649
+ if (this.shuttingDown) {
650
+ this.opts.log?.info(
651
+ `parall[${this.opts.accountId}]: drainMainBuffer halted (shutting down) — ${this.dispatchState.mainBuffer.length} buffered, ${this.dispatchState.pendingForkResults.length} pending fork results left for catch-up`,
652
+ );
653
+ break;
654
+ }
567
655
  if (this.dispatchState.mainBuffer.length === 0 && this.dispatchState.pendingForkResults.length > 0) {
568
- const forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
656
+ const pending = this.dispatchState.pendingForkResults.splice(0);
657
+ const forkPrefix = buildForkResultPrefix(pending);
569
658
  const syntheticEvent: ParallEvent = {
570
659
  type: "message",
571
660
  targetId: "_orchestrator",
@@ -576,7 +665,14 @@ export class ParallAgentGateway {
576
665
  body: "[Orchestrator: fork session(s) completed — review results above]",
577
666
  };
578
667
  this.dispatchState.mainCurrentTargetId = undefined;
579
- await this.runDispatch(syntheticEvent, this.opts.runtimeKey, forkPrefix + buildEventBody(syntheticEvent));
668
+ const dispatched = await this.runDispatch(syntheticEvent, this.opts.runtimeKey, forkPrefix + buildEventBody(syntheticEvent));
669
+ if (!dispatched) {
670
+ // Shutdown: put the fork results back at the head so the synthetic
671
+ // event is regenerated on the replacement pod. Synthetic events are
672
+ // not ack'd, so this is the only state we need to preserve.
673
+ this.dispatchState.pendingForkResults.unshift(...pending);
674
+ break;
675
+ }
580
676
  continue;
581
677
  }
582
678
 
@@ -588,12 +684,20 @@ export class ParallAgentGateway {
588
684
 
589
685
  const event = events[events.length - 1];
590
686
  const earlier = events.slice(0, -1);
591
- const forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
687
+ const pendingFork = this.dispatchState.pendingForkResults.splice(0);
688
+ const forkPrefix = buildForkResultPrefix(pendingFork);
592
689
  this.dispatchState.mainCurrentTargetId = event.targetId;
593
- if (earlier.length > 0) {
594
- await this.createInputStepsForEarlierEvents(earlier);
690
+ // Earlier-event input steps are persisted inside runDispatch (behind
691
+ // its shutdown gate) so a shutdown short-circuit cannot leave orphans.
692
+ const dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event), earlier);
693
+ if (!dispatched) {
694
+ // Shutdown: skip the ack so the server redelivers these buffered
695
+ // events to the replacement pod via dispatch catch-up. Put both the
696
+ // buffered events and the fork results back so nothing is lost.
697
+ this.dispatchState.mainBuffer.unshift(...events);
698
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
699
+ break;
595
700
  }
596
- await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event), earlier);
597
701
  for (const bufferedEvent of events) {
598
702
  const sourceType = bufferedEvent.ackSourceType ?? (bufferedEvent.type === "task" ? "task_activity" : "message");
599
703
  const sourceId = bufferedEvent.ackSourceId ?? bufferedEvent.messageId;
@@ -615,15 +719,22 @@ export class ParallAgentGateway {
615
719
 
616
720
  switch (disposition.action) {
617
721
  case "main": {
618
- const forkPrefix = buildForkResultPrefix(this.dispatchState.pendingForkResults.splice(0));
722
+ const pendingFork = this.dispatchState.pendingForkResults.splice(0);
723
+ const forkPrefix = buildForkResultPrefix(pendingFork);
619
724
  this.dispatchState.mainDispatching = true;
620
725
  this.dispatchState.mainCurrentTargetId = event.targetId;
726
+ let dispatched = false;
621
727
  try {
622
- await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
728
+ dispatched = await this.runDispatch(event, this.opts.runtimeKey, forkPrefix + buildEventBody(event));
729
+ if (!dispatched) {
730
+ // Shutdown short-circuit — restore the fork results so a future
731
+ // pod can replay them, and return false so handleMessage skips ack.
732
+ this.dispatchState.pendingForkResults.unshift(...pendingFork);
733
+ }
623
734
  } finally {
624
735
  await this.drainMainBuffer();
625
736
  }
626
- return true;
737
+ return dispatched;
627
738
  }
628
739
 
629
740
  case "buffer-main":
@@ -749,6 +860,7 @@ export class ParallAgentGateway {
749
860
  }
750
861
 
751
862
  private async handleMessage(data: MessageNewData) {
863
+ if (this.shuttingDown) return; // drain window — let server requeue via catch-up
752
864
  const chatId = data.chat_id;
753
865
  if (data.sender_id === this.opts.agentUserId) return;
754
866
  if (data.message_type !== "text") return;
@@ -779,6 +891,7 @@ export class ParallAgentGateway {
779
891
  }
780
892
 
781
893
  private async handleTaskAssignment(task: Task, ackSourceId?: string): Promise<boolean> {
894
+ if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
782
895
  const dedupeKey = `${task.id}:${task.updated_at}`;
783
896
  if (this.dispatchedTasks.has(dedupeKey)) {
784
897
  this.opts.log?.info(`parall[${this.opts.accountId}]: skipping already-dispatched task ${task.identifier ?? task.id}`);
@@ -814,6 +927,7 @@ export class ParallAgentGateway {
814
927
  }
815
928
 
816
929
  private async handleTaskComment(commentId: string, taskId: string, actorId: string | null): Promise<boolean> {
930
+ if (this.shuttingDown) return false; // drain window — let server requeue via catch-up
817
931
  const dedupeKey = `comment:${commentId}`;
818
932
  if (this.dispatchedTasks.has(dedupeKey)) return false;
819
933
  this.dispatchedTasks.add(dedupeKey);
@@ -895,6 +1009,9 @@ export class ParallAgentGateway {
895
1009
  });
896
1010
 
897
1011
  for (const item of page.data ?? []) {
1012
+ // Shutdown short-circuit: stop fetching/building work that runDispatch
1013
+ // will only reject. Items remain unacked for the replacement pod.
1014
+ if (this.shuttingDown) break;
898
1015
  if (minAge > 0 && new Date(item.created_at).getTime() < minAge) {
899
1016
  this.opts.client.ackDispatchByID(this.opts.config.org_id, item.id).catch(() => {});
900
1017
  skippedOld++;
@@ -976,7 +1093,9 @@ export class ParallAgentGateway {
976
1093
  this.opts.log?.warn(`parall[${this.opts.accountId}]: catch-up dispatch ${item.id} (${item.event_type}) failed: ${String(err)}`);
977
1094
  }
978
1095
  }
979
- cursor = page.has_more ? page.next_cursor : undefined;
1096
+ // Stop paginating once shutdown begins — the inner loop already broke,
1097
+ // and the next page would be wasted API work the replacement pod redoes.
1098
+ cursor = !this.shuttingDown && page.has_more ? page.next_cursor : undefined;
980
1099
  } while (cursor);
981
1100
 
982
1101
  if (processed > 0 || skippedOld > 0) {
@@ -1042,7 +1161,49 @@ export class ParallAgentGateway {
1042
1161
  }
1043
1162
  }
1044
1163
 
1164
+ // Resolves when in-flight dispatches hit 0 or the deadline elapses.
1165
+ // Deadline arming the resolver rather than rejecting keeps shutdown() linear
1166
+ // — callers don't need to try/catch.
1167
+ private waitForDrain(deadlineMs: number): Promise<void> {
1168
+ if (this.inFlightDispatches === 0) return Promise.resolve();
1169
+ return new Promise<void>((resolve) => {
1170
+ const timer = setTimeout(() => {
1171
+ // Remove this resolver so a late-completing dispatch doesn't call it.
1172
+ const idx = this.drainResolvers.indexOf(onDrain);
1173
+ if (idx >= 0) this.drainResolvers.splice(idx, 1);
1174
+ resolve();
1175
+ }, deadlineMs);
1176
+ const onDrain = () => {
1177
+ clearTimeout(timer);
1178
+ resolve();
1179
+ };
1180
+ this.drainResolvers.push(onDrain);
1181
+ });
1182
+ }
1183
+
1045
1184
  private async shutdown() {
1185
+ // Flip the flag first so any WS event that fires while we're draining
1186
+ // short-circuits at runDispatch instead of starting new work.
1187
+ this.shuttingDown = true;
1188
+
1189
+ // Drain: wait for in-flight dispatches to finish up to the configured
1190
+ // deadline. If we time out, whatever is still running will be killed by
1191
+ // the process exit — that's acceptable because K8s will retry the event
1192
+ // via dispatch catch-up after the new pod starts.
1193
+ if (this.inFlightDispatches > 0) {
1194
+ this.opts.log?.info(
1195
+ `parall[${this.opts.accountId}]: draining ${this.inFlightDispatches} in-flight dispatch(es), deadline ${this.SHUTDOWN_DEADLINE_MS}ms`,
1196
+ );
1197
+ await this.waitForDrain(this.SHUTDOWN_DEADLINE_MS);
1198
+ if (this.inFlightDispatches > 0) {
1199
+ this.opts.log?.warn(
1200
+ `parall[${this.opts.accountId}]: drain deadline hit; ${this.inFlightDispatches} dispatch(es) still running — they will be killed by process exit`,
1201
+ );
1202
+ } else {
1203
+ this.opts.log?.info(`parall[${this.opts.accountId}]: drain complete`);
1204
+ }
1205
+ }
1206
+
1046
1207
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
1047
1208
  for (const [, dispatch] of this.activeDispatches) {
1048
1209
  clearInterval(dispatch.typingTimer);
package/src/index.ts CHANGED
@@ -3,5 +3,6 @@ export * from "./session-state.js";
3
3
  export * from "./routing.js";
4
4
  export * from "./event-format.js";
5
5
  export * from "./prompt-fragments.js";
6
+ export * from "./bridge-workspace.js";
6
7
  export * from "./dispatch-adapter.js";
7
8
  export * from "./gateway-base.js";