@cjhyy/code-shell-core 0.9.4 → 0.9.6

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 (48) hide show
  1. package/dist/credentials/access.d.ts +9 -1
  2. package/dist/credentials/access.js +15 -4
  3. package/dist/credentials/store.d.ts +11 -0
  4. package/dist/credentials/store.js +44 -9
  5. package/dist/credentials/types.d.ts +4 -0
  6. package/dist/credentials/types.js +4 -0
  7. package/dist/credentials/use-credential-tool.js +12 -2
  8. package/dist/engine/engine-workspace-authority.js +10 -3
  9. package/dist/engine/engine.d.ts +3 -0
  10. package/dist/engine/engine.js +38 -6
  11. package/dist/engine/prompt-cache-diagnostics.js +10 -1
  12. package/dist/engine/run-goal.js +7 -3
  13. package/dist/engine/run-types.d.ts +16 -0
  14. package/dist/engine/run-workspace.js +5 -21
  15. package/dist/engine/turn-loop.d.ts +4 -4
  16. package/dist/engine/turn-loop.js +29 -9
  17. package/dist/index.d.ts +4 -4
  18. package/dist/index.js +2 -2
  19. package/dist/links/index.d.ts +1 -0
  20. package/dist/links/index.js +1 -0
  21. package/dist/links/link-action-tool.d.ts +2 -1
  22. package/dist/links/link-action-tool.js +80 -44
  23. package/dist/links/status.d.ts +53 -0
  24. package/dist/links/status.js +175 -0
  25. package/dist/llm/prompt-cache.d.ts +35 -3
  26. package/dist/llm/prompt-cache.js +63 -3
  27. package/dist/llm/providers/openai.d.ts +3 -0
  28. package/dist/llm/providers/openai.js +57 -14
  29. package/dist/protocol/background-result-wakeup.d.ts +8 -1
  30. package/dist/protocol/background-result-wakeup.js +76 -38
  31. package/dist/protocol/chat-session-manager.d.ts +7 -1
  32. package/dist/protocol/chat-session-manager.js +44 -8
  33. package/dist/protocol/chat-session.d.ts +2 -0
  34. package/dist/protocol/chat-session.js +4 -1
  35. package/dist/protocol/server.d.ts +3 -0
  36. package/dist/protocol/server.js +148 -43
  37. package/dist/protocol/session-message-result.d.ts +12 -0
  38. package/dist/protocol/session-message-result.js +42 -0
  39. package/dist/protocol/session-message-workspace.d.ts +21 -0
  40. package/dist/protocol/session-message-workspace.js +57 -0
  41. package/dist/protocol/types.d.ts +2 -0
  42. package/dist/session/session-message.d.ts +17 -2
  43. package/dist/tool-system/browser-bridge.d.ts +18 -0
  44. package/dist/tool-system/builtin/agent.js +23 -9
  45. package/dist/tool-system/builtin/browser-tools.js +18 -1
  46. package/dist/tool-system/builtin/index.js +3 -3
  47. package/dist/tool-system/builtin/send-message-to-session.js +19 -3
  48. package/package.json +1 -1
@@ -17,6 +17,10 @@ import { Methods, ErrorCodes, createResponse, createErrorResponse, createNotific
17
17
  import { diskDefaultsFrom } from "../engine/engine.js";
18
18
  import { ISOLATED_TASK_BEHAVIOR_MODE } from "../engine/run-types.js";
19
19
  import { isProtectedSettingKey, SettingsManager } from "../settings/manager.js";
20
+ import { sessionMessageOutcome, sessionMessageFailure, sessionMessageResultNotification, } from "./session-message-result.js";
21
+ import { resolveSessionMessageWorkspace } from "./session-message-workspace.js";
22
+ import { validateWorkspaceContext, workspacePrimaryRoot } from "../workspace/workspace-context.js";
23
+ import { canonicalKey } from "../workspace/canonical-key.js";
20
24
  import { getApprovalRouter, getInteractiveApprovalBackend, } from "../tool-system/permission.js";
21
25
  import { agentNotificationBus, notificationQueue, notificationEnvelopeToLegacyStreamEvent, } from "../tool-system/builtin/agent-notifications.js";
22
26
  import { backgroundShellManager } from "../runtime/background-shell.js";
@@ -593,6 +597,13 @@ export class AgentServer {
593
597
  approvalRouter: this.approvalRouter,
594
598
  onStream: (event) => this.notify(Methods.StreamEvent, { sessionId, event }),
595
599
  notificationMailbox: this.notificationMailbox,
600
+ resolveWorkspace: this.workspaceBridgeEnabled
601
+ ? async (session) => {
602
+ const slice = await this.resolveHostSessionWorkspace(session, sessionId);
603
+ this.rememberSessionSlice(sessionId, slice);
604
+ return slice;
605
+ }
606
+ : undefined,
596
607
  });
597
608
  }
598
609
  async rehydrateSessionForWake(sessionId) {
@@ -622,7 +633,8 @@ export class AgentServer {
622
633
  });
623
634
  return null;
624
635
  }
625
- const session = await this.chatManager.getOrCreate(sessionId, slice);
636
+ const session = await this.chatManager.getOrCreate(sessionId, slice, { allowReopen: false });
637
+ session.engine.restoreSessionModel?.(sessionId);
626
638
  this.wireInteractiveSession(session, sessionId);
627
639
  logger.debug("bg_wakeup.rehydrated_session", {
628
640
  sessionId,
@@ -672,6 +684,7 @@ export class AgentServer {
672
684
  }
673
685
  /** Queue a model-sent message as an ordinary user turn in another Session. */
674
686
  async routeSessionMessage(input) {
687
+ input.signal?.throwIfAborted();
675
688
  const manager = this.chatManager;
676
689
  if (!manager)
677
690
  throw new Error("cross-Session messaging requires a multi-session host");
@@ -680,12 +693,49 @@ export class AgentServer {
680
693
  throw new Error(`target Session is closing or closed: ${targetId}`);
681
694
  }
682
695
  const sourceSlice = this.lastSliceBySession.get(input.sourceSessionId);
683
- const targetSlice = {
684
- cwd: input.target.workspaceRoot,
685
- projectTrusted: sourceSlice?.projectTrusted ?? false,
696
+ const sourceSession = manager.get(input.sourceSessionId);
697
+ const assertStillAuthorized = () => {
698
+ input.signal?.throwIfAborted();
699
+ if (this.disconnected || manager.isUnavailable(targetId)) {
700
+ throw new Error(`target Session is closing or closed: ${targetId}`);
701
+ }
702
+ if (sourceSession &&
703
+ (manager.get(input.sourceSessionId) !== sourceSession ||
704
+ sourceSession.wasCancelledSinceLastTurn())) {
705
+ throw new Error("source Session was cancelled or closed before the message was queued");
706
+ }
686
707
  };
708
+ let targetSlice;
709
+ if (this.workspaceBridgeEnabled) {
710
+ if (!sourceSession)
711
+ throw new Error("source Session is no longer available");
712
+ targetSlice = await this.resolveHostSessionWorkspace(sourceSession, targetId);
713
+ }
714
+ else {
715
+ const sessionManager = sourceSession?.engine.getSessionManager?.() ??
716
+ (this.diskSessionReader ??= new SessionManager(this.sessionDiskRoot));
717
+ const sourceWorkspace = sourceSession?.engine.resolveSessionRunWorkspace?.(input.sourceSessionId);
718
+ targetSlice = resolveSessionMessageWorkspace({
719
+ sessionManager,
720
+ sourceSessionId: input.sourceSessionId,
721
+ targetSessionId: targetId,
722
+ sourceWorkspace: {
723
+ cwd: sourceWorkspace?.cwd ?? sourceSlice?.cwd ?? input.target.workspaceRoot,
724
+ workspaceContext: sourceWorkspace?.workspaceContext ?? sourceSlice?.workspaceContext,
725
+ projectTrusted: sourceSlice?.projectTrusted ?? false,
726
+ },
727
+ });
728
+ }
729
+ assertStillAuthorized();
687
730
  const targetAlreadyExists = manager.sessionExistsOnDisk(targetId, targetSlice);
688
- const targetSession = await manager.getOrCreate(targetId, targetSlice);
731
+ const targetWasResident = manager.get(targetId) !== undefined;
732
+ const targetSession = await manager.getOrCreate(targetId, targetSlice, {
733
+ allowReopen: false,
734
+ signal: input.signal,
735
+ });
736
+ assertStillAuthorized();
737
+ if (targetAlreadyExists && !targetWasResident)
738
+ targetSession.engine.restoreSessionModel?.(targetId);
689
739
  const approvalRegistration = this.approvalRouter.register(targetId, this.connectionId);
690
740
  if (!approvalRegistration.ok) {
691
741
  throw new Error(`target Session ${targetId} is owned by another connection`);
@@ -693,6 +743,43 @@ export class AgentServer {
693
743
  this.rememberSessionSlice(targetId, targetSlice);
694
744
  this.observeSessionAttached(targetId, targetSession.lastActivityAt);
695
745
  this.wireInteractiveSession(targetSession, targetId);
746
+ const messageId = `session-message-${nanoid(12)}`;
747
+ const state = { started: false, acknowledged: false, errorStreamed: false };
748
+ const finish = (outcome, result) => {
749
+ state.outcome = outcome;
750
+ state.result = result;
751
+ if (state.started)
752
+ this.observeRunBoundary(targetId, "end");
753
+ if (outcome.status !== "completed") {
754
+ logger.warn("session_message.turn_failed", {
755
+ sourceSessionId: input.sourceSessionId,
756
+ targetSessionId: targetId,
757
+ messageId,
758
+ error: outcome.error,
759
+ });
760
+ // Setup can return a failure without ever emitting an error event.
761
+ // UI observation must not prevent the durable reply from being queued.
762
+ try {
763
+ if (!state.errorStreamed) {
764
+ this.notify(Methods.StreamEvent, {
765
+ sessionId: targetId,
766
+ event: { type: "error", error: outcome.error ?? "cross-Session message failed" },
767
+ });
768
+ }
769
+ }
770
+ catch (error) {
771
+ logger.warn("session_message.error_observation_failed", {
772
+ messageId,
773
+ error: String(error),
774
+ });
775
+ }
776
+ }
777
+ if (state.acknowledged) {
778
+ this.notificationMailbox.enqueue(sessionMessageResultNotification(input, messageId, outcome));
779
+ }
780
+ if (result && result.turnCount > 0)
781
+ void this.maybeWakeIdleSession(targetId);
782
+ };
696
783
  const userMessageEvent = {
697
784
  type: "session_user_message",
698
785
  text: input.message,
@@ -700,50 +787,43 @@ export class AgentServer {
700
787
  this.observeSessionStream(targetId, userMessageEvent);
701
788
  this.notify(Methods.StreamEvent, { sessionId: targetId, event: userMessageEvent });
702
789
  const run = targetSession.enqueueTurn(input.message, {
703
- cwd: input.target.workspaceRoot,
790
+ cwd: targetSlice.cwd,
791
+ workspaceContext: targetSlice.workspaceContext,
704
792
  // A planned Session needs its renderer-selected initial profile. Once a
705
793
  // Session exists, omitting this field makes Engine use the target's own
706
794
  // persisted binding, so a stale source catalog cannot switch it back.
707
795
  workspaceProfile: targetAlreadyExists ? undefined : input.target.workspaceProfile,
708
796
  sessionMessageTargets: input.catalog,
709
797
  onStream: (event) => {
798
+ if (event.type === "session_started" && !state.started) {
799
+ state.started = true;
800
+ this.observeRunBoundary(targetId, "start");
801
+ }
802
+ if (event.type === "error")
803
+ state.errorStreamed = true;
710
804
  this.observeSessionStream(targetId, event);
711
805
  this.notify(Methods.StreamEvent, { sessionId: targetId, event });
712
806
  },
713
807
  approvalRouter: this.approvalRouter,
714
808
  });
715
- // The turn itself must NOT block the sender — a member's work can take
716
- // minutes and the lead has more to dispatch. But a turn that fails to *start*
717
- // (most often: the target's digital human is not installed, so run-setup
718
- // throws "Workspace profile ... is unavailable") used to be reported only to
719
- // the log while SendMessageToSession still answered "has queued the turn".
720
- // A real lead then waited two hours and re-sent, never learning the reason.
721
- // So: surface an immediate failure to the caller, keep a slow turn detached.
722
- let startupError;
723
- const tracked = run
724
- .then(() => this.maybeWakeIdleSession(targetId))
725
- .catch((error) => {
726
- startupError = error;
727
- logger.warn("session_message.turn_failed", {
728
- sourceSessionId: input.sourceSessionId,
729
- targetSessionId: targetId,
730
- error: error instanceof Error ? error.message : String(error),
731
- });
732
- this.notify(Methods.StreamEvent, {
733
- sessionId: targetId,
734
- event: {
735
- type: "error",
736
- error: error instanceof Error ? error.message : "cross-Session message failed",
737
- },
738
- });
739
- });
740
- // One macrotask is enough for a synchronous-throw path to settle; anything
741
- // still running by then is genuine work and stays detached.
809
+ const tracked = run.then((result) => finish(sessionMessageOutcome(result), result), (error) => finish(sessionMessageFailure(error)));
810
+ // Acknowledge promptly, but never infer "started" from elapsed time. If
811
+ // setup/work is still pending, its eventual outcome takes the mailbox path.
742
812
  await Promise.race([tracked, new Promise((resolve) => setTimeout(resolve, 0))]);
743
- if (startupError) {
744
- throw startupError instanceof Error ? startupError : new Error(String(startupError));
813
+ if (state.outcome) {
814
+ if (state.outcome.status !== "completed")
815
+ throw new Error(state.outcome.error);
816
+ return {
817
+ messageId,
818
+ status: "completed",
819
+ result: { text: state.outcome.text, reason: state.result.reason },
820
+ };
745
821
  }
746
- void tracked;
822
+ state.acknowledged = true;
823
+ void tracked.catch((error) => {
824
+ logger.warn("session_message.result_delivery_failed", { messageId, error: String(error) });
825
+ });
826
+ return { messageId, status: state.started ? "started" : "queued" };
747
827
  }
748
828
  // ─── Request Dispatch ───────────────────────────────────────────
749
829
  async handleRequest(req) {
@@ -1233,6 +1313,7 @@ export class AgentServer {
1233
1313
  (params.goal != null && typeof params.goal === "object")
1234
1314
  ? params.goal
1235
1315
  : undefined,
1316
+ disableGoal: params.disableGoal === true,
1236
1317
  onStream: (event) => {
1237
1318
  this.observeSessionStream(sid, event);
1238
1319
  this.notify(Methods.StreamEvent, { sessionId: sid, event });
@@ -3075,13 +3156,37 @@ export class AgentServer {
3075
3156
  });
3076
3157
  });
3077
3158
  }
3078
- requestWorkspaceSwitchForSession(session, sessionId, target) {
3159
+ async requestWorkspaceSwitchForSession(session, sessionId, target) {
3160
+ return (await this.requestWorkspaceActionForSession(session, sessionId, "switch", target));
3161
+ }
3162
+ /** The same host authority resolves both dispatched turns and their reply wakeups. */
3163
+ async resolveHostSessionWorkspace(source, targetId) {
3164
+ const registration = this.approvalRouter.register(source.id, this.connectionId);
3165
+ if (!registration.ok)
3166
+ throw new Error(`source Session ${source.id} is owned by another connection`);
3167
+ const resolved = (await this.requestWorkspaceActionForSession(source, source.id, "resolve_session_run", targetId));
3168
+ if (typeof resolved.cwd !== "string" ||
3169
+ !resolved.cwd ||
3170
+ typeof resolved.projectTrusted !== "boolean") {
3171
+ throw new Error("host returned an invalid Session workspace");
3172
+ }
3173
+ const workspaceContext = resolved.workspaceContext === undefined
3174
+ ? undefined
3175
+ : validateWorkspaceContext(resolved.workspaceContext);
3176
+ if (workspaceContext &&
3177
+ canonicalKey(workspacePrimaryRoot(workspaceContext).path) !== canonicalKey(resolved.cwd)) {
3178
+ throw new Error("host Session workspace does not match its working directory");
3179
+ }
3180
+ return { cwd: resolved.cwd, workspaceContext, projectTrusted: resolved.projectTrusted };
3181
+ }
3182
+ requestWorkspaceActionForSession(session, sessionId, action, target) {
3183
+ const label = action === "switch" ? "workspace switch" : "Session workspace resolution";
3079
3184
  return new Promise((resolve, reject) => {
3080
3185
  const requestId = nanoid(12);
3081
3186
  const routeEnvelope = this.approvalRouteEnvelope(sessionId, requestId);
3082
3187
  this.registerSessionApproval(session, this.internalPendingMetadata(sessionId, requestId, routeEnvelope, "__workspace_action__"), (decision) => {
3083
3188
  this.clearApprovalTimer(requestId);
3084
- const outcome = parseHostLoopbackDecision(decision, "workspace switch");
3189
+ const outcome = parseHostLoopbackDecision(decision, label);
3085
3190
  if (!outcome.ok) {
3086
3191
  reject(new Error(outcome.detail));
3087
3192
  return;
@@ -3094,11 +3199,11 @@ export class AgentServer {
3094
3199
  // parsed` TypeError happened to be caught and turned into a rejection;
3095
3200
  // this states the requirement instead of relying on that.)
3096
3201
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3097
- reject(new Error(`workspace switch ${HOST_LOOPBACK_FAILURE_DETAIL.malformed}`));
3202
+ reject(new Error(`${label} ${HOST_LOOPBACK_FAILURE_DETAIL.malformed}`));
3098
3203
  return;
3099
3204
  }
3100
3205
  if ("ok" in parsed && parsed.ok === false) {
3101
- reject(new Error(parsed.error ?? "workspace switch failed"));
3206
+ reject(new Error(parsed.error ?? `${label} failed`));
3102
3207
  return;
3103
3208
  }
3104
3209
  resolve(parsed);
@@ -3108,7 +3213,7 @@ export class AgentServer {
3108
3213
  this.takeSessionApproval(session, requestId, "expired");
3109
3214
  this.pendingApprovalTargets.delete(requestId);
3110
3215
  this.approvalTimers.delete(requestId);
3111
- reject(new Error(`workspace switch ${HOST_LOOPBACK_FAILURE_DETAIL.timed_out}`));
3216
+ reject(new Error(`${label} ${HOST_LOOPBACK_FAILURE_DETAIL.timed_out}`));
3112
3217
  }
3113
3218
  }, AgentServer.APPROVAL_TIMEOUT_MS);
3114
3219
  this.approvalTimers.set(requestId, timer);
@@ -3116,8 +3221,8 @@ export class AgentServer {
3116
3221
  ...routeEnvelope,
3117
3222
  request: {
3118
3223
  toolName: "__workspace_action__",
3119
- args: { action: "switch", target },
3120
- description: `workspace:switch:${target}`,
3224
+ args: { action, target },
3225
+ description: `workspace:${action}:${target}`,
3121
3226
  riskLevel: "low",
3122
3227
  },
3123
3228
  });
@@ -0,0 +1,12 @@
1
+ import type { EngineResult } from "../engine/types.js";
2
+ import type { RouteSessionMessageInput } from "../session/session-message.js";
3
+ import type { ResultEnvelopeDraft } from "../tool-system/builtin/agent-notifications.js";
4
+ export interface SessionMessageOutcome {
5
+ status: "completed" | "failed" | "cancelled";
6
+ text: string;
7
+ error?: string;
8
+ }
9
+ export declare function sessionMessageFailure(error: unknown): SessionMessageOutcome;
10
+ /** A resolved Engine promise can still represent a refusal or failed run. */
11
+ export declare function sessionMessageOutcome(result: EngineResult): SessionMessageOutcome;
12
+ export declare function sessionMessageResultNotification(input: RouteSessionMessageInput, messageId: string, outcome: SessionMessageOutcome): ResultEnvelopeDraft;
@@ -0,0 +1,42 @@
1
+ export function sessionMessageFailure(error) {
2
+ return {
3
+ status: error instanceof Error && error.name === "AbortError" ? "cancelled" : "failed",
4
+ text: "",
5
+ error: error instanceof Error ? error.message : String(error),
6
+ };
7
+ }
8
+ /** A resolved Engine promise can still represent a refusal or failed run. */
9
+ export function sessionMessageOutcome(result) {
10
+ if (result.reason === "aborted_streaming" || result.reason === "aborted_tools") {
11
+ return {
12
+ status: "cancelled",
13
+ text: result.text,
14
+ error: result.text || "Target turn was cancelled.",
15
+ };
16
+ }
17
+ if (result.reason === "completed" && result.turnCount > 0) {
18
+ return { status: "completed", text: result.text };
19
+ }
20
+ const detail = result.text ||
21
+ (result.turnCount === 0 ? "Target turn did not start." : "Target turn did not complete.");
22
+ return { status: "failed", text: result.text, error: `${detail} (reason: ${result.reason})` };
23
+ }
24
+ export function sessionMessageResultNotification(input, messageId, outcome) {
25
+ return {
26
+ kind: "result",
27
+ from: { sessionId: input.target.sessionId, authority: "agent" },
28
+ to: { sessionId: input.sourceSessionId, authority: "system" },
29
+ correlationId: messageId,
30
+ delivery: "idle-drain",
31
+ payload: {
32
+ workId: messageId,
33
+ name: input.target.title,
34
+ description: `Reply to message ${messageId} from Session ${input.target.sessionId}: ${input.message.slice(0, 240)}`,
35
+ workKind: "agent",
36
+ status: outcome.status,
37
+ finalText: outcome.text,
38
+ ...(outcome.error ? { error: outcome.error } : {}),
39
+ finishedAt: Date.now(),
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,21 @@
1
+ import type { SessionManager } from "../session/session-manager.js";
2
+ import { type WorkspaceContext } from "../workspace/workspace-context.js";
3
+ export interface SessionMessageWorkspace {
4
+ cwd: string;
5
+ workspaceContext?: WorkspaceContext;
6
+ projectTrusted?: boolean;
7
+ }
8
+ /**
9
+ * Protocol-only hosts can reuse already trusted mounted roots. Desktop should
10
+ * ask its project registry for fresh authority instead. Persisted Session ids,
11
+ * bindings and runtime roots always take precedence over a caller's catalog.
12
+ */
13
+ export declare function resolveSessionMessageWorkspace(input: {
14
+ sessionManager: Pick<SessionManager, "readSessionMainRoot" | "readSessionProjectBinding" | "getSessionWorkspace">;
15
+ sourceSessionId: string;
16
+ targetSessionId: string;
17
+ sourceWorkspace: SessionMessageWorkspace;
18
+ targetWorkspace?: SessionMessageWorkspace;
19
+ }): SessionMessageWorkspace & {
20
+ projectTrusted: boolean;
21
+ };
@@ -0,0 +1,57 @@
1
+ import { canonicalKey } from "../workspace/canonical-key.js";
2
+ import { createWorkspaceContext, validateWorkspaceContext, } from "../workspace/workspace-context.js";
3
+ /**
4
+ * Protocol-only hosts can reuse already trusted mounted roots. Desktop should
5
+ * ask its project registry for fresh authority instead. Persisted Session ids,
6
+ * bindings and runtime roots always take precedence over a caller's catalog.
7
+ */
8
+ export function resolveSessionMessageWorkspace(input) {
9
+ const { sessionManager, sourceSessionId, targetSessionId, sourceWorkspace, targetWorkspace } = input;
10
+ const sourceMain = sessionManager.readSessionMainRoot(sourceSessionId) ?? sourceWorkspace.cwd;
11
+ const targetMain = sessionManager.readSessionMainRoot(targetSessionId);
12
+ const sourceBinding = sessionManager.readSessionProjectBinding(sourceSessionId);
13
+ const targetBinding = sessionManager.readSessionProjectBinding(targetSessionId);
14
+ const cwd = sessionManager.getSessionWorkspace(targetSessionId)?.root ?? targetMain ?? sourceMain;
15
+ const sameMainRoot = !targetMain || canonicalKey(sourceMain) === canonicalKey(targetMain);
16
+ const projectTrusted = targetWorkspace?.projectTrusted ?? (sameMainRoot && sourceWorkspace.projectTrusted === true);
17
+ if (targetMain && !targetBinding) {
18
+ if (!sameMainRoot)
19
+ throw new Error("target Session does not share the source's authorized main root");
20
+ return { cwd, projectTrusted };
21
+ }
22
+ if (!targetBinding && !sourceBinding)
23
+ return { cwd, projectTrusted };
24
+ if (targetBinding && (!sourceBinding || targetBinding.projectId !== sourceBinding.projectId)) {
25
+ throw new Error("target Session project binding is not authorized by the source Session");
26
+ }
27
+ const binding = targetBinding ?? sourceBinding;
28
+ if (!sourceWorkspace.workspaceContext) {
29
+ throw new Error("cross-Session run requires an authoritative WorkspaceContext from the host");
30
+ }
31
+ const context = validateWorkspaceContext(sourceWorkspace.workspaceContext);
32
+ if (context.projectId !== sourceBinding?.projectId ||
33
+ context.sessionMainRootId !== sourceBinding.mainRootId) {
34
+ throw new Error("source WorkspaceContext does not match its persisted project binding");
35
+ }
36
+ // A source worktree is a runtime substitution, not a project mount that may
37
+ // be propagated into another Session's secondary roots.
38
+ const mountedRoots = context.roots.map((root) => root.id === sourceBinding.mainRootId ? { ...root, path: sourceMain } : { ...root });
39
+ const targetRoot = mountedRoots.find((root) => root.id === binding.mainRootId);
40
+ if (!targetRoot || (targetMain && canonicalKey(targetRoot.path) !== canonicalKey(targetMain))) {
41
+ throw new Error("target Session main root is not present in the host-authorized project roots");
42
+ }
43
+ return {
44
+ cwd,
45
+ projectTrusted,
46
+ workspaceContext: createWorkspaceContext({
47
+ projectId: context.projectId,
48
+ projectRevision: context.projectRevision,
49
+ sessionMainRootId: binding.mainRootId,
50
+ roots: mountedRoots.map((root) => ({
51
+ ...root,
52
+ path: root.id === binding.mainRootId ? cwd : root.path,
53
+ role: root.id === binding.mainRootId ? "primary" : "secondary",
54
+ })),
55
+ }),
56
+ };
57
+ }
@@ -198,6 +198,8 @@ export interface RunParams {
198
198
  * optional token/time budgets). Normalized at the engine run boundary.
199
199
  */
200
200
  goal?: string | import("../goal/lifecycle.js").GoalConfig;
201
+ /** Disable explicit, persisted, and configured Goal mode for this standalone turn. */
202
+ disableGoal?: boolean;
201
203
  }
202
204
  export interface RunResult {
203
205
  text: string;
@@ -9,14 +9,29 @@ export interface RouteSessionMessageInput {
9
9
  sourceSessionId: string;
10
10
  target: SessionMessageTarget;
11
11
  message: string;
12
+ /** Sender tool cancellation; never serialized or supplied by the model. */
13
+ signal?: AbortSignal;
12
14
  /** Full same-project catalog so the target can send a later message onward. */
13
15
  catalog: readonly SessionMessageTarget[];
14
16
  }
15
- export type SessionMessageRouter = (input: RouteSessionMessageInput) => Promise<void>;
17
+ /** One dispatch acknowledgement, not a subscription to future target turns. */
18
+ export interface SessionMessageReceipt {
19
+ messageId: string;
20
+ status: "queued" | "started" | "completed";
21
+ /** Present when the target finished before the dispatch returned. */
22
+ result?: {
23
+ text: string;
24
+ reason: import("../types.js").TerminalReason;
25
+ };
26
+ }
27
+ export type SessionMessageRouter = (input: RouteSessionMessageInput) => Promise<SessionMessageReceipt | void>;
16
28
  export interface SessionMessageToolService {
17
29
  targets: readonly SessionMessageTarget[];
18
30
  send(input: {
19
31
  targetSessionId: string;
20
32
  message: string;
21
- }): Promise<SessionMessageTarget>;
33
+ signal?: AbortSignal;
34
+ }): Promise<SessionMessageTarget & {
35
+ receipt?: SessionMessageReceipt;
36
+ }>;
22
37
  }
@@ -25,6 +25,22 @@ export interface BrowserElement {
25
25
  /** Optional value for non-sensitive inputs (e.g. current textbox text). */
26
26
  value?: string;
27
27
  }
28
+ /**
29
+ * Which browser identity a page is being viewed as.
30
+ *
31
+ * The model cannot otherwise tell a throwaway sandbox partition from the user's
32
+ * real logged-in browser, and the consequences of a click differ sharply
33
+ * between them. Optional so a host that only ever drives its own sandbox need
34
+ * not supply it. See docs/todo/browser-profile-workspace-lease-design.md §8.3.
35
+ */
36
+ export interface BrowserIdentity {
37
+ /** Profile the partition is derived from (e.g. "p:<project>" / "u:<name>"). */
38
+ profileId: string;
39
+ /** Where the browser itself comes from. */
40
+ sourceKind?: "builtin-panel" | "builtin-headless" | "attached-chrome" | "remote";
41
+ /** True when this is the user's own browser, carrying their real logins. */
42
+ isUserBrowser?: boolean;
43
+ }
28
44
  export interface BrowserSnapshot {
29
45
  url: string;
30
46
  title?: string;
@@ -38,6 +54,8 @@ export interface BrowserSnapshot {
38
54
  /** Set when the page needs the user to act (login wall / 2FA) — agent should
39
55
  * hand control back to the user rather than retry. */
40
56
  needsHuman?: string;
57
+ /** Which login identity this page is being viewed as (§8.3). */
58
+ identity?: BrowserIdentity;
41
59
  }
42
60
  export type BrowserResultCode = "OK" | "STALE_SNAPSHOT" | "STALE_CURSOR" | "NO_PROGRESS" | "NAVIGATION" | "BLOCKED" | "NEEDS_HUMAN" | "FAILED";
43
61
  export interface BrowserScrollState {
@@ -257,8 +257,8 @@ async function runSubAgent(spawner, opts,
257
257
  uiStream,
258
258
  /**
259
259
  * Optional override for the spawned child Engine's per-event stream.
260
- * Background path passes a transcriptSink here so per-event detail is
261
- * captured into the agent's transcript rather than the main feed. Sync
260
+ * Background path mirrors per-event detail into its transcript and the
261
+ * parent stream, where agentId keeps it scoped to the child card. Sync
262
262
  * calls leave undefined; engine.ts falls back to `spawner.parentStream`
263
263
  * (the parent UI), preserving the inline rendering of synchronous
264
264
  * sub-agents.
@@ -388,12 +388,27 @@ export async function agentTool(args, ctx) {
388
388
  // (per-instance closure state), so it doesn't interleave with other
389
389
  // agents.
390
390
  //
391
- // Sub-agent runs detached from the parent turn the parent feed only
392
- // sees the agent_start / agent_end markers via `parentStream` (the
393
- // 4th arg to runSubAgent); per-event detail goes through the
394
- // `streamOverride` (5th arg → SubAgentSpawnRequest.streamOverride →
395
- // engine.ts spawn closure routes to it instead of the main UI).
391
+ // Also forward the agent-scoped events so a desktop child card can show
392
+ // live operations even after the spawning parent turn has finished.
396
393
  const transcriptSink = createTranscriptTranslator(agentId);
394
+ const detailSink = (event) => {
395
+ transcriptSink(event);
396
+ safeEmit(parentStream, event);
397
+ };
398
+ const lifecycleSink = (event) => {
399
+ safeEmit(parentStream, event);
400
+ if (event.type === "agent_start") {
401
+ // Mark detached before spawn can emit any detail. Otherwise the parent
402
+ // turn's completion sweep prematurely seals this still-running card.
403
+ safeEmit(parentStream, {
404
+ type: "agent_backgrounded",
405
+ agentId,
406
+ name,
407
+ description,
408
+ agentType: overrides.resolvedType,
409
+ });
410
+ }
411
+ };
397
412
  void runSubAgent(spawner, {
398
413
  agentId,
399
414
  name,
@@ -429,8 +444,7 @@ export async function agentTool(args, ctx) {
429
444
  onProgressEvent: progressTracker.onEvent,
430
445
  onAgentProgress: progressTracker.onRuntime,
431
446
  signal: controller.signal,
432
- }, parentStream, // uiStream: agent_start/end → main feed
433
- transcriptSink)
447
+ }, lifecycleSink, detailSink)
434
448
  .then((text) => {
435
449
  if (controller.signal.aborted) {
436
450
  if (liveLease)
@@ -30,6 +30,23 @@ function bridge(ctx) {
30
30
  return ctx?.browser;
31
31
  }
32
32
  const STALE = (ref) => `Error: ref ${ref} is no longer valid (page changed). Re-run browser_observe.`;
33
+ /**
34
+ * One line naming the login identity this page is being viewed as.
35
+ *
36
+ * A profile id alone does not convey risk, so the user's own browser gets an
37
+ * explicit warning: actions there hit their real accounts, not a sandbox.
38
+ * Renders nothing when the host reports no identity, so hosts that only drive
39
+ * their own sandbox are unaffected.
40
+ */
41
+ function renderIdentity(identity) {
42
+ if (!identity)
43
+ return "";
44
+ const source = identity.sourceKind ? ` (${identity.sourceKind})` : "";
45
+ const warning = identity.isUserBrowser
46
+ ? " — this is the user's own browser: actions here affect their real accounts"
47
+ : "";
48
+ return `\nIdentity: ${identity.profileId}${source}${warning}`;
49
+ }
33
50
  // ════════════════════════════════════════════════════════════════════════════
34
51
  // browser_observe — observe the page (snapshot / read / extract)
35
52
  // ════════════════════════════════════════════════════════════════════════════
@@ -103,7 +120,7 @@ export async function browserObserveTool(args, ctx) {
103
120
  const snap = await b.snapshot();
104
121
  if (snap.detail)
105
122
  return `Error: ${snap.detail}`;
106
- const header = `URL: ${snap.url}${snap.title ? `\nTitle: ${snap.title}` : ""}`;
123
+ const header = `URL: ${snap.url}${snap.title ? `\nTitle: ${snap.title}` : ""}${renderIdentity(snap.identity)}`;
107
124
  const human = snap.needsHuman
108
125
  ? `\n\n⚠ ${snap.needsHuman} — please complete it in the browser window, then continue.`
109
126
  : "";
@@ -41,7 +41,7 @@ import { sendMessageToSessionToolDef, sendMessageToSessionTool, rewriteSendMessa
41
41
  import { useCredentialToolDef, useCredentialBuiltinTool, } from "../../credentials/use-credential-tool.js";
42
42
  import { injectCredentialToolDef, injectCredentialTool, isInjectCredentialAvailable, } from "../../credentials/inject-credential-tool.js";
43
43
  import { credentialAccessScope, getCredentialAccess } from "../../credentials/access.js";
44
- import { linkActionToolDef, linkActionTool, isLinkActionAvailable, } from "../../links/link-action-tool.js";
44
+ import { linkActionToolDef, linkActionTool } from "../../links/link-action-tool.js";
45
45
  const FAILURE_TEXT = /^(?:error\b|failed\b|failure\b|skill\b.*(?:not found\b|disabled\b|not available\b|allowlist\b|denied\b)|(?:[a-z][\w-]*\s+){1,3}(?:error\b|failed\b|failure\b|aborted\b|timed out\b))/i;
46
46
  function failureMessage(result) {
47
47
  if (typeof result.error === "string" && result.error)
@@ -831,7 +831,8 @@ const BUILTIN_CONTRIBUTIONS = [
831
831
  availability: (ctx) => isUseCredentialAvailable(ctx.cwd, ctx.settingsScope),
832
832
  }),
833
833
  },
834
- // Local Link actions resolve their provider-owned credential on every call.
834
+ // Link discovery remains available even when no saved connection is usable.
835
+ // Actions resolve their provider-owned credential on every call.
835
836
  // Read actions are allowed after the user connects a provider; write actions
836
837
  // use a closed, in-tool approval prompt so read and write share one catalog.
837
838
  {
@@ -845,7 +846,6 @@ const BUILTIN_CONTRIBUTIONS = [
845
846
  execute: linkActionTool,
846
847
  exposure: expose(GENERAL_TAGS, {
847
848
  defaultPermissionRules: allow(linkActionToolDef.name),
848
- availability: (ctx) => isLinkActionAvailable(ctx.cwd, ctx.settingsScope),
849
849
  }),
850
850
  },
851
851
  // InjectCredential:把 cookie 凭证注入内置浏览器(恢复登录态)。审批由工具内部