@cortexkit/aft-opencode 0.58.1 → 0.58.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.
Files changed (54) hide show
  1. package/dist/config-live-reload.d.ts +8 -0
  2. package/dist/config-live-reload.d.ts.map +1 -1
  3. package/dist/config.d.ts +13 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/entry/server.js +1603 -839
  6. package/dist/hashline-tag-sources.d.ts +18 -0
  7. package/dist/hashline-tag-sources.d.ts.map +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +980 -605
  10. package/dist/lsp-auto-install.d.ts +30 -0
  11. package/dist/lsp-auto-install.d.ts.map +1 -1
  12. package/dist/lsp-github-table.d.ts +4 -3
  13. package/dist/lsp-github-table.d.ts.map +1 -1
  14. package/dist/lsp-npm-table.d.ts +10 -0
  15. package/dist/lsp-npm-table.d.ts.map +1 -1
  16. package/dist/lsp-registry-probe.d.ts +8 -4
  17. package/dist/lsp-registry-probe.d.ts.map +1 -1
  18. package/dist/permissions/v2-service.d.ts +110 -5
  19. package/dist/permissions/v2-service.d.ts.map +1 -1
  20. package/dist/rpc/register.d.ts +0 -4
  21. package/dist/rpc/register.d.ts.map +1 -1
  22. package/dist/shared/session-directory.d.ts +7 -0
  23. package/dist/shared/session-directory.d.ts.map +1 -1
  24. package/dist/shared/subagent-detect.d.ts +11 -4
  25. package/dist/shared/subagent-detect.d.ts.map +1 -1
  26. package/dist/tool-registration.d.ts +8 -5
  27. package/dist/tool-registration.d.ts.map +1 -1
  28. package/dist/tools/_shared.d.ts +16 -15
  29. package/dist/tools/_shared.d.ts.map +1 -1
  30. package/dist/tools/bash.d.ts +24 -1
  31. package/dist/tools/bash.d.ts.map +1 -1
  32. package/dist/tools/bash_watch.d.ts +4 -4
  33. package/dist/tools/bash_watch.d.ts.map +1 -1
  34. package/dist/tools/bash_write.d.ts.map +1 -1
  35. package/dist/tools/definitions/v2.d.ts +2 -0
  36. package/dist/tools/definitions/v2.d.ts.map +1 -1
  37. package/dist/tools/hoisted/v2.d.ts +8 -1
  38. package/dist/tools/hoisted/v2.d.ts.map +1 -1
  39. package/dist/tools/hoisted.d.ts +9 -7
  40. package/dist/tools/hoisted.d.ts.map +1 -1
  41. package/dist/tools/reading.d.ts +0 -3
  42. package/dist/tools/reading.d.ts.map +1 -1
  43. package/dist/v2-prompt-detach.d.ts +69 -0
  44. package/dist/v2-prompt-detach.d.ts.map +1 -0
  45. package/dist/wakes/runtime-consumer.d.ts +2 -0
  46. package/dist/wakes/runtime-consumer.d.ts.map +1 -1
  47. package/dist/wakes/session-delivery.d.ts +9 -0
  48. package/dist/wakes/session-delivery.d.ts.map +1 -1
  49. package/dist/workflow-hints.d.ts +20 -7
  50. package/dist/workflow-hints.d.ts.map +1 -1
  51. package/package.json +8 -8
  52. package/src/entry/server-runtime.mjs +21 -5
  53. package/src/shared/session-directory.ts +92 -0
  54. package/src/shared/subagent-detect.ts +89 -28
@@ -20,14 +20,16 @@ export declare const _buildUnifiedDiffForTest: (fp: string, before: string, afte
20
20
  export declare function createReadTool(ctx: PluginContext): ToolDefinition;
21
21
  /**
22
22
  * Returns AFT's host-slot tools keyed by the host's built-in names, plus the
23
- * move/delete tools and the bash companions.
23
+ * move/delete tools and, while `bash.background` is on, the bash companions.
24
24
  *
25
- * Every entry is always returned; `buildAftToolDefinitions` removes only the
26
- * names listed in the resolved `disabled_tools`. The bash companions
27
- * (`bash_status`, `bash_write`, `bash_watch`, `bash_kill`) are independent
28
- * registrations: disabling `bash` does not remove them, and when the bash
29
- * runtime gate (`bash.enabled`) is off every bash operation reports
30
- * `bash_disabled` instead of disappearing.
25
+ * `buildAftToolDefinitions` then removes the names listed in the resolved
26
+ * `disabled_tools`. The bash companions (`bash_status`, `bash_write`,
27
+ * `bash_watch`, `bash_kill`) are independent of `bash` itself: disabling
28
+ * `bash` does not remove them, and when the bash runtime gate (`bash.enabled`)
29
+ * is off every bash operation reports `bash_disabled` instead of disappearing.
30
+ * They exist only to inspect, wait on, feed and stop background tasks, so with
31
+ * `bash.background` off, when no background task can exist, they are not
32
+ * registered at all.
31
33
  */
32
34
  export declare function hoistedTools(ctx: PluginContext): Record<string, ToolDefinition>;
33
35
  //# sourceMappingURL=hoisted.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"hoisted.d.ts","sourceRoot":"","sources":["../../src/tools/hoisted.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAKH,OAAO,KAAK,EAAe,cAAc,EAAc,MAAM,qBAAqB,CAAC;AAMnF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AA4CjD,yFAAyF;AACzF,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAE/E;AAaD,wEAAwE;AACxE,eAAO,MAAM,wBAAwB,GAAI,IAAI,MAAM,EAAE,QAAQ,MAAM,EAAE,OAAO,MAAM,KAAG,MAChD,CAAC;AAqPtC;;GAEG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,aAAa,GAAG,cAAc,CAiKjE;AAkxBD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAgB/E"}
1
+ {"version":3,"file":"hoisted.d.ts","sourceRoot":"","sources":["../../src/tools/hoisted.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAUH,OAAO,KAAK,EAAe,cAAc,EAAc,MAAM,qBAAqB,CAAC;AAOnF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AA4CjD,yFAAyF;AACzF,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAE/E;AAaD,wEAAwE;AACxE,eAAO,MAAM,wBAAwB,GAAI,IAAI,MAAM,EAAE,QAAQ,MAAM,EAAE,OAAO,MAAM,KAAG,MAChD,CAAC;AAqPtC;;GAEG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,aAAa,GAAG,cAAc,CA2JjE;AA+wBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAsB/E"}
@@ -11,9 +11,6 @@ interface ZoomBatchResult {
11
11
  symbols: ZoomBatchSymbolResult[];
12
12
  text: string;
13
13
  }
14
- /**
15
- * Tool definitions for code reading commands: outline + zoom.
16
- */
17
14
  export declare function readingTools(ctx: PluginContext): Record<string, ToolDefinition>;
18
15
  /**
19
16
  * Format multi-symbol zoom results as plain text. Successful entries use
@@ -1 +1 @@
1
- {"version":3,"file":"reading.d.ts","sourceRoot":"","sources":["../../src/tools/reading.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAe,cAAc,EAAc,MAAM,qBAAqB,CAAC;AAInF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAyCjD,UAAU,qBAAqB;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,eAAe;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,qBAAqB,EAAE,CAAC;IACjC,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;GAEG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CA4S/E;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACnC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EAAE,EACjB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GACnC,eAAe,CAyBjB"}
1
+ {"version":3,"file":"reading.d.ts","sourceRoot":"","sources":["../../src/tools/reading.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAe,cAAc,EAAc,MAAM,qBAAqB,CAAC;AAInF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AA8CjD,UAAU,qBAAqB;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,eAAe;IACvB,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,qBAAqB,EAAE,CAAC;IACjC,IAAI,EAAE,MAAM,CAAC;CACd;AAsBD,wBAAgB,YAAY,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CA2R/E;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACnC,WAAW,EAAE,MAAM,EACnB,OAAO,EAAE,MAAM,EAAE,EACjB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GACnC,eAAe,CAyBjB"}
@@ -0,0 +1,69 @@
1
+ import { Effect } from "effect";
2
+ import { interruptBashWaitsForChatMessage } from "./bash-wait-detach.js";
3
+ import type { AftConfig } from "./config.js";
4
+ /** An offset range into `prompt.text`; `end` is exclusive. */
5
+ interface PromptMention {
6
+ start: number;
7
+ end: number;
8
+ text: string;
9
+ }
10
+ interface MentionCarrier {
11
+ mention?: PromptMention;
12
+ }
13
+ /** The fields of OpenCode 2's `SessionHooks["prompt"]` event this hook reads or edits. */
14
+ export interface V2PromptHookEvent {
15
+ readonly sessionID: string;
16
+ prompt: {
17
+ text: string;
18
+ files?: MentionCarrier[];
19
+ agents?: MentionCarrier[];
20
+ skills?: MentionCarrier[];
21
+ };
22
+ metadata?: Record<string, unknown>;
23
+ }
24
+ type ChatMessagePool = Parameters<typeof interruptBashWaitsForChatMessage>[0];
25
+ export interface V2PromptDetachRuntime {
26
+ /** The Location's bridge pool, where a session's waits live. */
27
+ readonly pool: ChatMessagePool;
28
+ /**
29
+ * The root this Location's tools run on (the Location's own directory); its
30
+ * bridge is asked to detach before any other.
31
+ */
32
+ readonly projectRoot: string;
33
+ /**
34
+ * Read on every prompt so a live config reload of
35
+ * `bash.detach_on_user_message` applies to the next message.
36
+ */
37
+ getConfig(): AftConfig;
38
+ }
39
+ /**
40
+ * Run the shared chat-message decision on one OpenCode 2 prompt and write the
41
+ * stripped text (and moved mention offsets) back onto it. Returns whether the
42
+ * session's waits were interrupted.
43
+ *
44
+ * A prompt AFT admitted itself (a completion wake) is passed to the shared
45
+ * decision as a synthetic part, exactly as OpenCode 1 marks such parts: it
46
+ * interrupts a wait whenever `bash.detach_on_user_message` is on, but its text
47
+ * is never read for `&detach` and never edited.
48
+ *
49
+ * All edits are computed before any is applied, so a throw leaves the prompt
50
+ * exactly as it arrived.
51
+ */
52
+ export declare function interruptBashWaitsForV2Prompt(runtime: V2PromptDetachRuntime, event: V2PromptHookEvent): boolean;
53
+ /**
54
+ * The callback registered as OpenCode 2's session `prompt` hook. The host
55
+ * waits for it before admitting the message and a failure would reject the
56
+ * message, so it never fails and never waits: errors are caught, logged once
57
+ * per distinct reason, and the prompt passes through unchanged; the bridge
58
+ * detach request it may start is not awaited.
59
+ */
60
+ export declare function createV2PromptDetachHook(runtime: V2PromptDetachRuntime): (event: V2PromptHookEvent) => Effect.Effect<void>;
61
+ /**
62
+ * Register the prompt hook on an OpenCode 2 host context. The registration is
63
+ * scoped by the host, so it ends with the Location. A host context without
64
+ * `session.hook` gets no hook, which leaves waits un-detachable by messages
65
+ * but otherwise working.
66
+ */
67
+ export declare function registerV2PromptDetachHook(context: unknown, runtime: V2PromptDetachRuntime): Effect.Effect<boolean, never, unknown>;
68
+ export {};
69
+ //# sourceMappingURL=v2-prompt-detach.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"v2-prompt-detach.d.ts","sourceRoot":"","sources":["../src/v2-prompt-detach.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,OAAO,EAAE,gCAAgC,EAAE,MAAM,uBAAuB,CAAC;AACzE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAI7C,8DAA8D;AAC9D,UAAU,aAAa;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;CACd;AAED,UAAU,cAAc;IACtB,OAAO,CAAC,EAAE,aAAa,CAAC;CACzB;AAED,0FAA0F;AAC1F,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,MAAM,EAAE;QACN,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,CAAC,EAAE,cAAc,EAAE,CAAC;QACzB,MAAM,CAAC,EAAE,cAAc,EAAE,CAAC;QAC1B,MAAM,CAAC,EAAE,cAAc,EAAE,CAAC;KAC3B,CAAC;IACF,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,gCAAgC,CAAC,CAAC,CAAC,CAAC,CAAC;AAE9E,MAAM,WAAW,qBAAqB;IACpC,gEAAgE;IAChE,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,SAAS,IAAI,SAAS,CAAC;CACxB;AAuDD;;;;;;;;;;;;GAYG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,qBAAqB,EAC9B,KAAK,EAAE,iBAAiB,GACvB,OAAO,CAuBT;AAMD;;;;;;GAMG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,EAAE,qBAAqB,GAC7B,CAAC,KAAK,EAAE,iBAAiB,KAAK,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAenD;AAOD;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,qBAAqB,GAC7B,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,KAAK,EAAE,OAAO,CAAC,CAYxC"}
@@ -4,6 +4,8 @@ import { type V2SessionAdmission } from "./session-delivery.js";
4
4
  export interface V2WakeHostContext {
5
5
  readonly session: V2SessionAdmission;
6
6
  }
7
+ /** Test-only: forget every route and held completion in the process-wide table. */
8
+ export declare function __resetV2WakeRoutesForTests(): void;
7
9
  declare const sharedBridgeOptions: Pick<BridgeOptions, "onBashCompletion" | "onBashLongRunning">;
8
10
  export interface V2RuntimeConsumer extends V2ToolConsumers {
9
11
  readonly bridgeOptions: typeof sharedBridgeOptions;
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-consumer.d.ts","sourceRoot":"","sources":["../../src/wakes/runtime-consumer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAIV,aAAa,EACd,MAAM,uBAAuB,CAAC;AAK/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAE,KAAK,kBAAkB,EAAqB,MAAM,uBAAuB,CAAC;AAEnF,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,EAAE,kBAAkB,CAAC;CACtC;AA0DD,QAAA,MAAM,mBAAmB,EAAE,IAAI,CAAC,aAAa,EAAE,kBAAkB,GAAG,mBAAmB,CAGtF,CAAC;AAEF,MAAM,WAAW,iBAAkB,SAAQ,eAAe;IACxD,QAAQ,CAAC,aAAa,EAAE,OAAO,mBAAmB,CAAC;IACnD,OAAO,IAAI,IAAI,CAAC;CACjB;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,iBAAiB,GAAG,iBAAiB,CAwBrF"}
1
+ {"version":3,"file":"runtime-consumer.d.ts","sourceRoot":"","sources":["../../src/wakes/runtime-consumer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAIV,aAAa,EACd,MAAM,uBAAuB,CAAC;AAM/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAClE,OAAO,EAAE,KAAK,kBAAkB,EAAqB,MAAM,uBAAuB,CAAC;AAEnF,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,OAAO,EAAE,kBAAkB,CAAC;CACtC;AAkJD,mFAAmF;AACnF,wBAAgB,2BAA2B,IAAI,IAAI,CAKlD;AAED,QAAA,MAAM,mBAAmB,EAAE,IAAI,CAAC,aAAa,EAAE,kBAAkB,GAAG,mBAAmB,CAGtF,CAAC;AAEF,MAAM,WAAW,iBAAkB,SAAQ,eAAe;IACxD,QAAQ,CAAC,aAAa,EAAE,OAAO,mBAAmB,CAAC;IACnD,OAAO,IAAI,IAAI,CAAC;CACjB;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,iBAAiB,GAAG,iBAAiB,CAiErF"}
@@ -1,4 +1,13 @@
1
1
  import { Effect } from "effect";
2
+ /**
3
+ * The `metadata.source` value on every prompt AFT admits into a session itself
4
+ * (background-completion wakes). OpenCode 2 runs every prompt, AFT's own
5
+ * included, through the session `prompt` hook; the hook reads this marker to
6
+ * tell AFT's machine-written text from text an operator typed, never the text.
7
+ */
8
+ export declare const AFT_PROMPT_SOURCE = "aft";
9
+ /** True when a prompt's metadata marks it as one AFT admitted itself. */
10
+ export declare function isAftOriginatedPrompt(metadata: unknown): boolean;
2
11
  export interface V2SessionPromptInput {
3
12
  readonly sessionID: string;
4
13
  readonly text: string;
@@ -1 +1 @@
1
- {"version":3,"file":"session-delivery.d.ts","sourceRoot":"","sources":["../../src/wakes/session-delivery.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACtD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACtD,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC;CACxB;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,KAAK,EAAE,oBAAoB,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IACrE,SAAS,CAAC,KAAK,EAAE,uBAAuB,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;CAC5E;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AAED;;;GAGG;AACH,qBAAa,iBAAiB;IAIhB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAHpC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;IACzD,OAAO,CAAC,QAAQ,CAAS;gBAEI,OAAO,EAAE,kBAAkB;IAExD,UAAU,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC;IAuBlE,MAAM,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC;IAe7D,OAAO,IAAI,IAAI;CAKhB"}
1
+ {"version":3,"file":"session-delivery.d.ts","sourceRoot":"","sources":["../../src/wakes/session-delivery.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC;;;;;GAKG;AACH,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC,yEAAyE;AACzE,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,OAAO,GAAG,OAAO,CAMhE;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACtD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACtD,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAC;CACxB;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,KAAK,EAAE,oBAAoB,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IACrE,SAAS,CAAC,KAAK,EAAE,uBAAuB,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;CAC5E;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD;AAED;;;GAGG;AACH,qBAAa,iBAAiB;IAIhB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAHpC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;IACzD,OAAO,CAAC,QAAQ,CAAS;gBAEI,OAAO,EAAE,kBAAkB;IAExD,UAAU,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,OAAO,CAAC;IA0BlE,MAAM,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,OAAO,CAAC;IAe7D,OAAO,IAAI,IAAI;CAKhB"}
@@ -4,20 +4,25 @@ export interface WorkflowHintsOpts {
4
4
  bashBackgroundEnabled: boolean;
5
5
  /** Resolved bash compression flag. */
6
6
  bashCompressionEnabled: boolean;
7
+ /**
8
+ * Resolved `bash.rewrite` flag. Only rewritten `cat`/`head`/`tail` commands
9
+ * mint hashline tags through bash, so the hashline hint names them only when
10
+ * rewriting is on. Omitted means on.
11
+ */
12
+ bashRewriteEnabled?: boolean;
7
13
  /** Set of tool names that are not registered. */
8
14
  disabledTools: Set<string>;
9
15
  /** Whether the hashline `edit` arm is the one actually registered. */
10
16
  hashlineEffective?: boolean;
11
17
  }
12
18
  /**
13
- * Routing rule for hashline sessions.
14
- *
15
- * Navigation tools return source that looks edit-ready but never publishes a
16
- * snapshot, so an agent that inspects a symbol and then patches it is refused
17
- * for a tag it believes it already has. Naming the tag-minting tools is the
18
- * only way to distinguish "I have seen this code" from "I can address it".
19
+ * Routing rule for hashline sessions: which calls mint the tags a patch
20
+ * addresses (see `hashlineTagSourceSentence`). The bash rewrite path and each
21
+ * AFT navigation tool are named only when present.
19
22
  */
20
- export declare const HASHLINE_TAG_SOURCE_HINT = "**Hashline edit tags**: Only `read` (and accepted AFT `cat`/`head`/`tail` rewrites) mint hashline tags. `aft_zoom`, `aft_outline`, `grep`, `aft_search`, and conflict snippets do not. After navigation, call `read` on every file and range the patch addresses.";
23
+ export declare function hashlineTagSourceHint(bashRewritesMintTags: boolean, isRegistered?: (toolName: string) => boolean): string;
24
+ /** The hashline hint for a session whose bash rewrites `cat`/`head`/`tail`. */
25
+ export declare const HASHLINE_TAG_SOURCE_HINT: string;
21
26
  /**
22
27
  * Build the workflow hints block. Returns `null` when no hints are
23
28
  * applicable for the configured surface (e.g. `tool_surface: "minimal"`
@@ -32,6 +37,14 @@ export declare function buildWorkflowHints(opts: WorkflowHintsOpts): string | nu
32
37
  * `bash.background` setting controls whether the hint appears.
33
38
  */
34
39
  export declare function buildHintsFromConfig(config: AftConfig, disabledTools: Set<string>, hashlineEffective?: boolean): string | null;
40
+ /** Every tool name a workflow-hints section can mention. */
41
+ export declare const WORKFLOW_HINT_TOOL_NAMES: readonly string[];
42
+ /**
43
+ * Build the hints block for the tools that are actually registered. Every
44
+ * hint-relevant name missing from `registeredTools` is passed as absent, so
45
+ * sections for tools the agent cannot call are dropped.
46
+ */
47
+ export declare function buildHintsForRegisteredTools(config: AftConfig, registeredTools: ReadonlySet<string>, hashlineEffective?: boolean): string | null;
35
48
  /**
36
49
  * Fold the hints block into a host `system[]` array in place.
37
50
  *
@@ -1 +1 @@
1
- {"version":3,"file":"workflow-hints.d.ts","sourceRoot":"","sources":["../src/workflow-hints.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,KAAK,SAAS,EAAqB,MAAM,aAAa,CAAC;AAEhE,MAAM,WAAW,iBAAiB;IAChC,2DAA2D;IAC3D,qBAAqB,EAAE,OAAO,CAAC;IAC/B,sCAAsC;IACtC,sBAAsB,EAAE,OAAO,CAAC;IAChC,iDAAiD;IACjD,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,sEAAsE;IACtE,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAID;;;;;;;GAOG;AACH,eAAO,MAAM,wBAAwB,sQACgO,CAAC;AAEtQ;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,GAAG,IAAI,CAwIzE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,EACjB,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,iBAAiB,UAAQ,GACxB,MAAM,GAAG,IAAI,CAOf;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAQ9E"}
1
+ {"version":3,"file":"workflow-hints.d.ts","sourceRoot":"","sources":["../src/workflow-hints.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,KAAK,SAAS,EAAqB,MAAM,aAAa,CAAC;AAGhE,MAAM,WAAW,iBAAiB;IAChC,2DAA2D;IAC3D,qBAAqB,EAAE,OAAO,CAAC;IAC/B,sCAAsC;IACtC,sBAAsB,EAAE,OAAO,CAAC;IAChC;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,iDAAiD;IACjD,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC3B,sEAAsE;IACtE,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAID;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,oBAAoB,EAAE,OAAO,EAC7B,YAAY,GAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAoB,GACvD,MAAM,CAER;AAED,+EAA+E;AAC/E,eAAO,MAAM,wBAAwB,QAA8B,CAAC;AAEpE;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,GAAG,IAAI,CAkJzE;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,SAAS,EACjB,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,iBAAiB,UAAQ,GACxB,MAAM,GAAG,IAAI,CASf;AAED,4DAA4D;AAC5D,eAAO,MAAM,wBAAwB,EAAE,SAAS,MAAM,EAYrD,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,4BAA4B,CAC1C,MAAM,EAAE,SAAS,EACjB,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,EACpC,iBAAiB,UAAQ,GACxB,MAAM,GAAG,IAAI,CAGf;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,CAQ9E"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/aft-opencode",
3
- "version": "0.58.1",
3
+ "version": "0.58.2",
4
4
  "type": "module",
5
5
  "description": "OpenCode plugin for Agent File Tools (AFT) — tree-sitter and lsp powered code analysis",
6
6
  "main": "dist/index.js",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@clack/prompts": "^1.6.0",
42
- "@cortexkit/aft-bridge": "0.58.1",
42
+ "@cortexkit/aft-bridge": "0.58.2",
43
43
  "@opencode/client": "2.0.11",
44
44
  "@opentui/core": "0.4.3",
45
45
  "@opentui/solid": "0.4.3",
@@ -50,12 +50,12 @@
50
50
  "zod": "^4.4.3"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@cortexkit/aft-darwin-arm64": "0.58.1",
54
- "@cortexkit/aft-darwin-x64": "0.58.1",
55
- "@cortexkit/aft-linux-arm64": "0.58.1",
56
- "@cortexkit/aft-linux-x64": "0.58.1",
57
- "@cortexkit/aft-win32-arm64": "0.58.1",
58
- "@cortexkit/aft-win32-x64": "0.58.1"
53
+ "@cortexkit/aft-darwin-arm64": "0.58.2",
54
+ "@cortexkit/aft-darwin-x64": "0.58.2",
55
+ "@cortexkit/aft-linux-arm64": "0.58.2",
56
+ "@cortexkit/aft-linux-x64": "0.58.2",
57
+ "@cortexkit/aft-win32-arm64": "0.58.2",
58
+ "@cortexkit/aft-win32-x64": "0.58.2"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@opencode-ai/plugin": "^1.17.11",
@@ -17,7 +17,8 @@ import { startOpenCodeLiveConfigReload } from "../config-live-reload.js";
17
17
  import { debug, log, warn } from "../logger.js";
18
18
  import { resolvePluginVersion } from "../plugin-version.js";
19
19
  import { registerAftConfigErrorRpc, registerAftRpc } from "../rpc/register.js";
20
- import { hoistedV2ToolConsumers } from "../tools/hoisted/v2.js";
20
+ import { hoistedV2ToolConsumers, v2PromptChannelFor } from "../tools/hoisted/v2.js";
21
+ import { registerV2PromptDetachHook } from "../v2-prompt-detach.js";
21
22
  import { createV2RuntimeConsumer } from "../wakes/runtime-consumer.js";
22
23
  import {
23
24
  buildAftToolDefinitions,
@@ -34,8 +35,11 @@ const defaults = {
34
35
  registerTools: registerAftTools,
35
36
  registerRpc: registerAftRpc,
36
37
  registerConfigErrorRpc: registerAftConfigErrorRpc,
37
- toolConsumers: (context) => ({
38
- ...hoistedV2ToolConsumers(context),
38
+ registerPromptHook: registerV2PromptDetachHook,
39
+ // The prompt server comes from the user-only `opencode` block of the
40
+ // Location's config; without it AFT finds the server it runs inside.
41
+ toolConsumers: (context, config) => ({
42
+ ...hoistedV2ToolConsumers(context, v2PromptChannelFor(config?.opencode)),
39
43
  ...createV2RuntimeConsumer(context),
40
44
  }),
41
45
  acquireBridge,
@@ -75,7 +79,7 @@ async function bootLocation(context, location, dependencies) {
75
79
  dependencies,
76
80
  );
77
81
  const canonicalDirectory = location.project?.canonical ?? directory;
78
- const consumers = dependencies.toolConsumers(context);
82
+ const consumers = dependencies.toolConsumers(context, config);
79
83
  // Only a rejected configuration keeps AFT out of a project; there is no
80
84
  // config switch that turns it off.
81
85
  const isProjectEnabled = createProjectAcceptance(directory, dependencies);
@@ -127,7 +131,7 @@ async function bootLocation(context, location, dependencies) {
127
131
  },
128
132
  notify,
129
133
  });
130
- return { consumers, pool, tools, liveConfigReload };
134
+ return { consumers, pool, tools, liveConfigReload, toolContext, directory };
131
135
  }
132
136
 
133
137
  /**
@@ -206,6 +210,18 @@ export function makeServerEffect(overrides = {}) {
206
210
  );
207
211
  const rpc = yield* dependencies.registerRpc(context, location, runtime.pool);
208
212
  yield* Effect.addFinalizer(() => Effect.promise(() => rpc.dispose()));
213
+ // OpenCode 2 has no chat.message hook; its session prompt hook is where a
214
+ // new message detaches a waiting bash, as chat.message does on OpenCode 1.
215
+ // The config is read through the tool context so live reloads apply.
216
+ // The bridge asked first is the one this Location's tools run on, the
217
+ // Location's own directory; for a Location in a linked git worktree that
218
+ // is the worktree, not the canonical main checkout the pool was acquired
219
+ // for (issue #387). Other bridges are still tried after it.
220
+ yield* dependencies.registerPromptHook(context, {
221
+ pool: runtime.pool,
222
+ projectRoot: runtime.directory,
223
+ getConfig: () => runtime.toolContext.config,
224
+ });
209
225
  yield* dependencies.registerTools(
210
226
  context,
211
227
  location,
@@ -19,12 +19,62 @@
19
19
  * `packages/opencode/src/session/registry.ts`. Until then this
20
20
  * workaround makes AFT robust against the wrong cwd.
21
21
  */
22
+ import { Effect } from "effect";
23
+
22
24
  import { sessionWarn } from "../logger.js";
23
25
 
24
26
  interface SessionInfo {
25
27
  directory?: string;
26
28
  }
27
29
 
30
+ /**
31
+ * OpenCode 2 passes its plugin context where OpenCode 1 passes the SDK client
32
+ * (`entry/server-runtime.mjs` hands the context over as `client`). That context
33
+ * has a different session API, verified against the `v2` branch of the OpenCode repository:
34
+ *
35
+ * - `session.get` takes `{ sessionID }` and returns an Effect of `Session.Info`
36
+ * (packages/client/src/effect/api/api.ts `SessionGetOperation`,
37
+ * packages/plugin/src/effect/plugin.ts `Context`). Calling it the OpenCode 1
38
+ * way builds an Effect that never runs, and awaiting an Effect yields the
39
+ * Effect itself, so no directory was ever read.
40
+ * - `Session.Info` has no `directory`; the session's working directory is
41
+ * `location.directory`, a required `Location.Ref`
42
+ * (packages/schema/src/session.ts `Info`, packages/schema/src/location.ts
43
+ * `Ref`). For a session created in a linked git worktree it is that worktree.
44
+ *
45
+ * It is told apart from an OpenCode 1 SDK client by having a string
46
+ * `location.directory` next to a `session.get` function; the OpenCode 2 entry
47
+ * refuses to boot without that `location`, and `subagent-detect.ts` uses the
48
+ * same test for its own session lookup.
49
+ */
50
+ interface V2SessionInfo {
51
+ location?: { directory?: unknown };
52
+ }
53
+
54
+ interface V2PluginContextShape {
55
+ location: { directory: string };
56
+ session: {
57
+ get(input: { sessionID: string }): Effect.Effect<V2SessionInfo | undefined, unknown>;
58
+ };
59
+ }
60
+
61
+ function isV2PluginContext(client: unknown): client is V2PluginContextShape {
62
+ if (!client || typeof client !== "object") return false;
63
+ const candidate = client as { location?: { directory?: unknown }; session?: { get?: unknown } };
64
+ return (
65
+ typeof candidate.location?.directory === "string" &&
66
+ typeof candidate.session?.get === "function"
67
+ );
68
+ }
69
+
70
+ /**
71
+ * How long an OpenCode 2 session lookup may run. The lookup is awaited before
72
+ * the first tool call of a session is routed, so a host that never answers
73
+ * must not hold that call; after this long the call proceeds with the
74
+ * Location's directory instead.
75
+ */
76
+ export const V2_SESSION_LOOKUP_TIMEOUT_MS = 3_000;
77
+
28
78
  interface OpenCodeClientShape {
29
79
  session?: {
30
80
  get?: (input: {
@@ -71,6 +121,8 @@ export async function getSessionDirectory(
71
121
  return cached.directory;
72
122
  }
73
123
 
124
+ if (isV2PluginContext(client)) return lookupV2SessionDirectory(client, sessionId);
125
+
74
126
  const c = client as OpenCodeClientShape;
75
127
  const sessionApi = c?.session;
76
128
  if (!sessionApi || typeof sessionApi.get !== "function") {
@@ -108,6 +160,46 @@ export async function getSessionDirectory(
108
160
  return dir;
109
161
  }
110
162
 
163
+ /**
164
+ * OpenCode 2 lookup: run the context's `session.get` Effect under a timeout and
165
+ * read `location.directory`.
166
+ *
167
+ * Unlike the OpenCode 1 path, a failure or timeout is cached (as `null`), so it
168
+ * is logged once per session and never retried. The fallback is already right:
169
+ * with no cached directory, `projectRootFor` uses the tool call's Location
170
+ * directory for OpenCode 2 runtimes, and OpenCode 2 runs every session inside
171
+ * the Location created for that session's own directory. Retrying would only
172
+ * add up to the timeout to every later tool call while the host misbehaves.
173
+ */
174
+ async function lookupV2SessionDirectory(
175
+ context: V2PluginContextShape,
176
+ sessionId: string,
177
+ ): Promise<string | null> {
178
+ let dir: string | null = null;
179
+ let failure: string | undefined;
180
+ try {
181
+ const session = await Effect.runPromise(
182
+ Effect.timeout(
183
+ context.session.get({ sessionID: sessionId }) as Effect.Effect<V2SessionInfo | undefined>,
184
+ V2_SESSION_LOOKUP_TIMEOUT_MS,
185
+ ),
186
+ );
187
+ const directory = session?.location?.directory;
188
+ if (typeof directory === "string" && directory.length > 0) dir = directory;
189
+ else failure = "the session record has no location.directory";
190
+ } catch (err) {
191
+ failure = err instanceof Error ? err.message || err.name : String(err);
192
+ }
193
+ if (failure !== undefined) {
194
+ sessionWarn(
195
+ sessionId,
196
+ `[aft-plugin] OpenCode 2 session lookup failed (${failure}); tool paths and bash use the Location directory ${context.location.directory} for this session`,
197
+ );
198
+ }
199
+ setCache(sessionId, dir);
200
+ return dir;
201
+ }
202
+
111
203
  function setCache(sessionId: string, directory: string | null): void {
112
204
  if (cache.has(sessionId)) cache.delete(sessionId);
113
205
  cache.set(sessionId, { directory, recordedAt: Date.now() });
@@ -21,10 +21,14 @@
21
21
  * identity is sticky (parentID never changes after session creation), so
22
22
  * the cache never needs invalidation.
23
23
  *
24
- * Errors are not cached. If the SDK is briefly unavailable on the first
25
- * call, we retry on the next bash invocation rather than permanently
26
- * misclassifying. This is cheap because the cache is hit on every
27
- * subsequent call within the same session.
24
+ * Every bridge call asks for the role (AFT words its replies by it), so the
25
+ * lookup must stay cheap and bounded:
26
+ * - concurrent first calls for one session share a single lookup;
27
+ * - a lookup that has not answered within LOOKUP_TIMEOUT_MS is abandoned and
28
+ * that call proceeds as primary, so a slow host can't delay a read;
29
+ * - a failed or abandoned lookup is cached as "primary" for
30
+ * FAILED_LOOKUP_RETRY_MS, then retried, so a struggling host is not asked on
31
+ * every call but the real answer is still picked up later.
28
32
  *
29
33
  * Mirrors the pattern in `session-directory.ts`.
30
34
  */
@@ -48,20 +52,33 @@ interface OpenCodeClientShape {
48
52
 
49
53
  interface CacheEntry {
50
54
  isSubagent: boolean;
51
- recordedAt: number;
55
+ /**
56
+ * When a provisional "primary" answer (recorded after a failed or timed-out
57
+ * lookup) stops counting and the host is asked again. Absent for a real
58
+ * answer, which never changes: a session's parent is fixed at creation.
59
+ */
60
+ retryAfter?: number;
52
61
  }
53
62
 
54
63
  const CACHE_MAX_ENTRIES = 200;
64
+ /** Longest a caller waits for the host's session lookup. */
65
+ export const LOOKUP_TIMEOUT_MS = 1_000;
66
+ /** How long a failed or timed-out lookup is answered as "primary" before retrying. */
67
+ export const FAILED_LOOKUP_RETRY_MS = 30_000;
55
68
  const cache = new Map<string, CacheEntry>();
69
+ /** One lookup per session at a time; concurrent first calls await it. */
70
+ const inflight = new Map<string, Promise<boolean>>();
71
+ let now: () => number = () => Date.now();
56
72
 
57
73
  /**
58
74
  * Returns `true` when the given session has a non-empty `parentID`
59
75
  * (subagent). Returns `false` for primary sessions, when the SDK is
60
- * unavailable, or when any error occurs — the false default keeps
61
- * primary sessions working exactly as before.
76
+ * unavailable, or when the lookup fails or takes longer than
77
+ * LOOKUP_TIMEOUT_MS; the false default keeps primary sessions working
78
+ * exactly as before.
62
79
  *
63
- * First call per session: one SDK round-trip. All subsequent calls:
64
- * O(1) cache lookup.
80
+ * A known answer is an O(1) cache hit. Otherwise at most one host lookup
81
+ * per session is in flight, and it is bounded by LOOKUP_TIMEOUT_MS.
65
82
  */
66
83
  export async function resolveIsSubagent(
67
84
  client: unknown,
@@ -74,7 +91,7 @@ export async function resolveIsSubagent(
74
91
  }
75
92
 
76
93
  const cached = cache.get(sessionId);
77
- if (cached) {
94
+ if (cached && (cached.retryAfter === undefined || now() < cached.retryAfter)) {
78
95
  // Refresh LRU position. Don't log the hit — it's pure noise; the
79
96
  // downstream call sites already log their effective gate decisions.
80
97
  cache.delete(sessionId);
@@ -82,6 +99,48 @@ export async function resolveIsSubagent(
82
99
  return cached.isSubagent;
83
100
  }
84
101
 
102
+ const pending = inflight.get(sessionId);
103
+ if (pending) return pending;
104
+ const lookup = boundedLookup(client, sessionId).finally(() => {
105
+ inflight.delete(sessionId);
106
+ });
107
+ inflight.set(sessionId, lookup);
108
+ return lookup;
109
+ }
110
+
111
+ /**
112
+ * Runs the host lookup, giving up after LOOKUP_TIMEOUT_MS. A real answer is
113
+ * cached for good; a failure or timeout is cached as "primary" until
114
+ * FAILED_LOOKUP_RETRY_MS has passed. The abandoned host call is left to
115
+ * settle on its own; its late answer is ignored.
116
+ */
117
+ async function boundedLookup(client: unknown, sessionId: string): Promise<boolean> {
118
+ let timer: ReturnType<typeof setTimeout> | undefined;
119
+ const timedOut = new Promise<"timeout">((resolve) => {
120
+ timer = setTimeout(() => resolve("timeout"), LOOKUP_TIMEOUT_MS);
121
+ });
122
+ let answer: boolean | "failed" | "timeout";
123
+ try {
124
+ answer = await Promise.race([lookupSession(client, sessionId), timedOut]);
125
+ } finally {
126
+ clearTimeout(timer);
127
+ }
128
+ if (typeof answer === "boolean") {
129
+ setCache(sessionId, answer);
130
+ return answer;
131
+ }
132
+ if (answer === "timeout") {
133
+ sessionWarn(
134
+ sessionId,
135
+ `[subagent-detect] session lookup did not answer within ${LOOKUP_TIMEOUT_MS}ms → primary for now`,
136
+ );
137
+ }
138
+ setCache(sessionId, false, now() + FAILED_LOOKUP_RETRY_MS);
139
+ return false;
140
+ }
141
+
142
+ /** The host's answer, or "failed" when the lookup threw. Never rejects. */
143
+ async function lookupSession(client: unknown, sessionId: string): Promise<boolean | "failed"> {
85
144
  if (isV2PluginContext(client)) return resolveV2(client, sessionId);
86
145
 
87
146
  const c = client as OpenCodeClientShape;
@@ -93,7 +152,6 @@ export async function resolveIsSubagent(
93
152
  sessionId,
94
153
  `[subagent-detect] client.session.get unavailable (client=${typeof client}, session=${typeof sessionApi}, get=${typeof sessionApi?.get}) → caching as primary`,
95
154
  );
96
- setCache(sessionId, false);
97
155
  return false;
98
156
  }
99
157
 
@@ -102,8 +160,6 @@ export async function resolveIsSubagent(
102
160
  `[subagent-detect] cache miss, calling client.session.get(id=${sessionId})`,
103
161
  );
104
162
 
105
- let isSubagent = false;
106
- let parentIdRaw: unknown;
107
163
  try {
108
164
  // Call as a method so the SDK's `this._client` reference resolves
109
165
  // correctly. Extracting `sessionApi.get` into a local would lose
@@ -119,25 +175,21 @@ export async function resolveIsSubagent(
119
175
  // `Session` depending on `ThrowOnError`. Handle both shapes.
120
176
  const session: SessionInfo | undefined =
121
177
  (result as { data?: SessionInfo } | undefined)?.data ?? (result as SessionInfo | undefined);
122
- parentIdRaw = session?.parentID;
123
- isSubagent =
178
+ const parentIdRaw = session?.parentID;
179
+ const isSubagent =
124
180
  session !== undefined && typeof session.parentID === "string" && session.parentID.length > 0;
125
181
  sessionLog(
126
182
  sessionId,
127
183
  `[subagent-detect] SDK returned session=${session !== undefined ? "present" : "undefined"}, parentID=${JSON.stringify(parentIdRaw)} → isSubagent=${isSubagent}`,
128
184
  );
185
+ return isSubagent;
129
186
  } catch (err) {
130
- // Don't poison the cache on transient errors — but do log once.
131
- // Return false so primary-session behavior is preserved.
132
187
  sessionWarn(
133
188
  sessionId,
134
189
  `[subagent-detect] SDK lookup failed: ${err instanceof Error ? err.message : String(err)}`,
135
190
  );
136
- return false;
191
+ return "failed";
137
192
  }
138
-
139
- setCache(sessionId, isSubagent);
140
- return isSubagent;
141
193
  }
142
194
 
143
195
  /**
@@ -166,33 +218,35 @@ function isV2PluginContext(client: unknown): client is V2PluginContextShape {
166
218
  );
167
219
  }
168
220
 
169
- async function resolveV2(context: V2PluginContextShape, sessionId: string): Promise<boolean> {
221
+ async function resolveV2(
222
+ context: V2PluginContextShape,
223
+ sessionId: string,
224
+ ): Promise<boolean | "failed"> {
170
225
  let session: SessionInfo | undefined;
171
226
  try {
172
227
  session = await Effect.runPromise(
173
228
  context.session.get({ sessionID: sessionId }) as Effect.Effect<SessionInfo | undefined>,
174
229
  );
175
230
  } catch (err) {
176
- // Same policy as the V1 path: a failed lookup is not cached, and the call
177
- // is treated as primary for now so the next tool call can retry.
231
+ // Same policy as the V1 path: a failed lookup counts as primary only
232
+ // until FAILED_LOOKUP_RETRY_MS has passed.
178
233
  sessionWarn(
179
234
  sessionId,
180
235
  `[subagent-detect] OpenCode 2 session lookup failed: ${err instanceof Error ? err.message : String(err)}`,
181
236
  );
182
- return false;
237
+ return "failed";
183
238
  }
184
239
  const isSubagent = typeof session?.parentID === "string" && session.parentID.length > 0;
185
240
  sessionLog(
186
241
  sessionId,
187
242
  `[subagent-detect] OpenCode 2 session parentID=${JSON.stringify(session?.parentID)} → isSubagent=${isSubagent}`,
188
243
  );
189
- setCache(sessionId, isSubagent);
190
244
  return isSubagent;
191
245
  }
192
246
 
193
- function setCache(sessionId: string, isSubagent: boolean): void {
247
+ function setCache(sessionId: string, isSubagent: boolean, retryAfter?: number): void {
194
248
  if (cache.has(sessionId)) cache.delete(sessionId);
195
- cache.set(sessionId, { isSubagent, recordedAt: Date.now() });
249
+ cache.set(sessionId, retryAfter === undefined ? { isSubagent } : { isSubagent, retryAfter });
196
250
  if (cache.size > CACHE_MAX_ENTRIES) {
197
251
  const oldest = cache.keys().next().value;
198
252
  if (oldest !== undefined) cache.delete(oldest);
@@ -202,4 +256,11 @@ function setCache(sessionId: string, isSubagent: boolean): void {
202
256
  /** Test-only cache reset. Not exported from the public surface. */
203
257
  export function _resetSubagentCacheForTest(): void {
204
258
  cache.clear();
259
+ inflight.clear();
260
+ now = () => Date.now();
261
+ }
262
+
263
+ /** Test-only clock override for the failed-lookup retry window. */
264
+ export function _setSubagentClockForTest(clock: () => number): void {
265
+ now = clock;
205
266
  }