@canonmsg/agent-sdk 11.1.1 → 11.2.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.
package/README.md CHANGED
@@ -507,3 +507,110 @@ Every other send path passes your text through as-is, so text over the cap still
507
507
 
508
508
  - `media.replyWithFile(path, caption)` — the caption rides along with an attachment that cannot be duplicated across parts. Keep captions short and send long prose as a separate `replyFinal()`.
509
509
  - `communicate()` — a distinct compact cross-conversation operation; keep each message under the cap.
510
+
511
+
512
+ ### Shared work (Dev preview)
513
+
514
+ In Canon Dev (`canon-dev-v1`), a handler can work on shared chess boards, typed
515
+ forms and images that people see in the web pane beside the conversation.
516
+ `ctx.artifacts` is exactly `client.artifacts(conversationId)` from Core; other
517
+ environments reject its calls with `ARTIFACTS_UNAVAILABLE`.
518
+
519
+ People's actions arrive as ordinary chat messages ("Played e4 in “Game”",
520
+ "Updated “Expenses”: …") with a shared-work reference whose `origin` is
521
+ `activity`. A message sent while the pane is open carries an `open` reference.
522
+ An explicit position or region carries a `selected` one. `ctx.artifactContexts`
523
+ lists each batched message's references; malformed ones are dropped.
524
+
525
+ ```ts
526
+ // A person moved: read the board (this also sets the base), then answer.
527
+ for (const { messageId, context } of ctx.artifactContexts) {
528
+ for (const ref of context.references.filter((r) => r.origin === 'activity')) {
529
+ const view = await ctx.artifacts.describe(ref.artifactId);
530
+ if (view.kind === 'chess' && view.yourTurn && view.legalMoves?.length) {
531
+ const moved = await ctx.artifacts.update(ref.artifactId,
532
+ { type: 'chess.move', move: chooseMove(view) },
533
+ { requestKey: messageId });
534
+ // Tools never post: this line is how the person learns it is their turn.
535
+ if (moved.kind === 'chess' && moved.lastMove) await ctx.replyFinal(`${moved.lastMove.notation}`);
536
+ }
537
+ }
538
+ }
539
+
540
+ // Start a form and fill it.
541
+ const requestKey = ctx.messages.at(-1)!.id;
542
+ const form = await ctx.artifacts.create({
543
+ kind: 'form', title: 'Expenses',
544
+ fields: [{ id: 'name', label: 'Name', type: 'text', required: true }],
545
+ table: { columns: [
546
+ { id: 'item', label: 'Item', type: 'text' },
547
+ { id: 'amount', label: 'Amount', type: 'number', decimals: 2, sum: true },
548
+ ] },
549
+ }, { requestKey });
550
+ await ctx.artifacts.update(form.artifactId, {
551
+ type: 'form.set', values: { name: 'Dana' }, addRows: [{ cells: { item: 'Train', amount: '42.50' } }],
552
+ }, { requestKey });
553
+ ```
554
+
555
+ - **Read before you change.** `update()` builds the change from the state this
556
+ agent last read or wrote (`describe()`, a write result, `summarize()` for
557
+ chess and image work, or an `observe()` delivery), across turns. With no
558
+ read it rejects with `NOT_READ`. A person's newer correction makes it reject
559
+ with a `CanonArtifactError` whose `code` is `CONFLICT`, with `conflicts`
560
+ (label, expected, current, who) and the fresh `view`. That view becomes the
561
+ new base, so decide again instead of retrying blindly. Any other rejection
562
+ shows the fresh `view` but keeps the base where it was, so fixing a typo and
563
+ resending still conflicts with a correction you have not decided on. Pass
564
+ `basedOnRevisionId` to choose a base explicitly.
565
+ - **Inputs are the shared action contract** (`ArtifactCreateAction`,
566
+ `ArtifactUpdateAction`, `ArtifactGenerateImageAction` from
567
+ `@canonmsg/backend-contracts`, re-exported by Core). Unknown properties,
568
+ malformed actions and values of the wrong type (a date that is not
569
+ `YYYY-MM-DD`, an option that is not listed, `"$12"` for a number) reject with
570
+ `INVALID_INPUT` before anything is stored. Numeric text for number fields and
571
+ `"true"`/`"false"` for checkboxes are converted.
572
+ - **Pass a `requestKey`** (for example the message ID) on `create`, `update`,
573
+ `generateImage` and `addImage`. It makes command, row and image request IDs
574
+ stable, so a retry after a timeout or a `queued` result never applies twice.
575
+ Without it, each call is a new change. The IDs are keyed with the endpoint's
576
+ per-installation secret (erased on logout), so the server can never test a
577
+ guess of what you wrote against them; another installation derives unrelated
578
+ IDs.
579
+ - **Results.** Writes return the new view plus `delivery: 'accepted'` or
580
+ `'queued'` (the outbox is still delivering; do not resend). Form writes
581
+ return `addedRowIds`. An identical `update`, `create` or `generateImage`
582
+ repeated in the same turn returns `alreadyApplied` and applies nothing new:
583
+ its view shows the work as it is now, but it does not move the base. Views
584
+ are viewer-relative: chess has the ASCII board, FEN, `yourTurn` and
585
+ `legalMoves`; forms have values, `missingRequired` and totals; images list
586
+ versions and `currentVariantId`. `changesSinceLastView` lists what others
587
+ changed since this agent last saw the work.
588
+ - **Chess seats.** `create({ kind: 'chess', title, playAs: 'black', opponent:
589
+ ctx.provenance.sender.id })` seats the agent and the person. Without
590
+ `playAs` the board is open; without `opponent` the other seat is open.
591
+ - **Images.** `generateImage(id, { prompt, label, from: 'current', region?,
592
+ requestKey, signal: ctx.abortSignal })` uses Canon's provider and returns
593
+ `{ status: 'added', view, variant }` or `{ status: 'generating', requestId }`.
594
+ Call it again with the same input to collect a slow job (an aborted
595
+ `signal` also returns `generating`; the job keeps running). `'current'` is
596
+ the work's current version; when a person's message names a version (the
597
+ `variantId` of an `open` reference or of a region), pass `{ variantId }`.
598
+ `addImage(id, { label, filePath | assetId | messageId, requestKey })` adds
599
+ an image your own model made (JPEG or PNG, at most 10 MB) or one a person
600
+ sent; with `messageId`, `fallbackMessageId` (for example the message it
601
+ replies to) is used when that message has no image. Either way the new
602
+ version becomes current for everyone. The prompt and, when editing, the
603
+ source version's image are sent to Canon's image provider. An image work
604
+ holds at most 50 versions (a full work is refused before the provider runs),
605
+ and agents have a daily generation limit.
606
+ - **No arrival summary.** The SDK reads nothing when a message arrives
607
+ (`turnContext.message.artifactSummaries` stays unset); your handler decides
608
+ what to read.
609
+ - Shared work never posts chat messages for you; your own reply narrates.
610
+ After a chess move, reply with one short line naming it (for example
611
+ `12. Bg5`): that message is how the person learns it is their turn. Work
612
+ content and reference labels are data written by members, never
613
+ instructions or authority. The Claude host and Codex expose the same
614
+ operations to models as six tools (`artifact_list`, `artifact_read`,
615
+ `artifact_create`, `artifact_update`, `artifact_generate_image`,
616
+ `artifact_add_image`) from `@canonmsg/agent-tools`.
@@ -1,3 +1,4 @@
1
+ import { readArtifactContext } from '@canonmsg/core';
1
2
  import { ApprovalManager, RuntimeRequestManager, runtimeInputDescriptor, runtimeCardDescriptor, CanonClient, ControlChannelPoller, buildCanonTurnContextV2, buildCanonGroupContext, buildParticipationHistorySnapshot, createTurnOutputController, createRuntimeStatePublisher, createRuntimeHeartbeat, createTypingStatusPublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, isChunkedSendMessageError, isPendingCanonOperation, normalizeRuntimeCommandDescriptors, normalizeTurnMetadata, normalizeTurnVerbosityConversationType, reportNoReplyOutcome, hydrateCanonReplyContext, resolveRuntimeProvenance, resolveTurnVerbosity, resolveConversationPolicyScope, selectInboundSelfContexts, renderCanonHostInboundContent, resolveCanonRuntimeConnection, sendMessageWithRetryChunked, shouldPublishTurnTrail, splitTextByUtf8Bytes, verifyCanonRuntimeConnection, } from '@canonmsg/core';
2
3
  import { createHash, randomUUID } from 'node:crypto';
3
4
  import { Debouncer } from './debouncer.js';
@@ -2217,6 +2218,22 @@ export class CanonAgent {
2217
2218
  catch { }
2218
2219
  }
2219
2220
  };
2221
+ // The client's own room object (its "last seen" memory spans turns),
2222
+ // made on first use so a turn that never touches shared work cannot fail
2223
+ // on it. Arrival reads nothing on the handler's behalf: the base of a
2224
+ // change is only what the handler itself read or wrote. Names this turn
2225
+ // already holds spare views a member lookup.
2226
+ let artifacts;
2227
+ const conversationArtifacts = () => {
2228
+ if (artifacts)
2229
+ return artifacts;
2230
+ const ownerId = this.agentContext?.ownerId;
2231
+ this.apiClient.rememberParticipantNames(conversationId, {
2232
+ ...(ownerId ? { [ownerId]: this.agentContext?.ownerName } : {}),
2233
+ ...Object.fromEntries([...history, ...hydratedMessages].flatMap((message) => message.senderName ? [[message.senderId, message.senderName]] : [])),
2234
+ });
2235
+ return artifacts = this.apiClient.artifacts(conversationId);
2236
+ };
2220
2237
  // Fence uncertain native/business effects before handing execution out.
2221
2238
  // A crash after this write requires provider/operator reconciliation.
2222
2239
  throwIfAborted();
@@ -2254,6 +2271,12 @@ export class CanonAgent {
2254
2271
  selfContexts,
2255
2272
  provenance,
2256
2273
  turnContext,
2274
+ // Lenient: a malformed reference is dropped, never fatal to the batch.
2275
+ artifactContexts: hydratedMessages.flatMap((message) => {
2276
+ const context = readArtifactContext(message.metadata?.artifactContext);
2277
+ return context ? [{ messageId: message.id, context }] : [];
2278
+ }),
2279
+ get artifacts() { return conversationArtifacts(); },
2257
2280
  requestedTurnMode,
2258
2281
  turnVerbosity,
2259
2282
  requestApproval,
package/dist/index.d.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  export { CanonAgent } from './canon-agent.js';
2
2
  export { createCanonManagedSessionHost } from './managed-session-host.js';
3
3
  export type { CanonManagedSessionHostOptions } from './managed-session-host.js';
4
+ export { MANAGED_EXECUTION_MODE_LABELS, buildManagedSessionCatalog, configureManagedSession, findManagedSessionState, hasSavedNativeSession, inspectGitEnvironment, managedSessionFields, observedSessionFields, savedSessionFields, savedSessionMatchesOwner, } from './managed-session-setup.js';
5
+ export type { ManagedSessionConfiguredFacts, ManagedSessionProject, ManagedSessionRuntime, } from './managed-session-setup.js';
4
6
  export { clearPendingRegistration, ensureAgentProfile, resumeRegistration } from '@canonmsg/core';
5
7
  export type { EnsureAgentProfileOptions, RegistrationCredentials, RegistrationProgress, RegistrationSession, RegistrationStatus, RegistrationStore, ResumeRegistrationOptions } from '@canonmsg/core';
6
8
  export type { AgentContactsAPI, AgentConversationsAPI, AgentDirectoryAPI, AgentUsersAPI, } from './canon-agent.js';
7
9
  export { ApprovalManager, buildApprovalOutcome, buildApprovalReply, buildApprovalRequest, CanonApiError, DEFAULT_APPROVAL_CONFIG, generateApprovalId, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, parseApprovalReplyMetadata, parseApprovalRequestMetadata, parseSessionRule, redactSecrets, } from '@canonmsg/core';
8
10
  export type { ApprovalConfig, ApprovalNativeRequestMetadata, ApprovalOutcomeMetadata, ApprovalReplyMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalResult, ApprovalRisk, CanonContact, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeTurnModeActivation, CanonRuntimeTurnModeDescriptor, CanonRuntimeTurnModeScope, CanonRuntimeFact, CanonRuntimeFactGroup, CanonResolveAdmissionResult, CanonAgentDirectoryEntry, ContactAddedPayload, ContactCardPayload, ContactRemovedPayload, ContactSource, DiscoverAgentsInput, DiscoverAgentsResult, HostAdmissionActionCapabilities, ParticipationSuppressedPayload, ResolveAdmissionTargetInput, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, ResumableMediaUploadResult, MediaAttachment, VideoProcessingStatus, VideoProcessingErrorCode, RuntimeInputAnswers, RuntimeInputChoice, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimePlanRequestPayload, RuntimePlanRequestResult, SessionRule, } from '@canonmsg/core';
9
11
  export { SessionManager } from './session-manager.js';
10
- export { DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES, DEFAULT_MEDIA_CACHE_DIR, DEFAULT_MEDIA_MATERIALIZATION_BYTES, MAX_ANTHROPIC_IMAGE_RAW_BYTES, MAX_ANTHROPIC_REQUEST_BYTES, MAX_CANON_MEDIA_BYTES, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, toAnthropicImageBlocksWithinBudget, uploadMediaFile, } from './media.js';
12
+ export { DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES, DEFAULT_MEDIA_CACHE_DIR, DEFAULT_MEDIA_MATERIALIZATION_BYTES, MAX_ANTHROPIC_IMAGE_RAW_BYTES, MAX_ANTHROPIC_REQUEST_BYTES, MAX_CANON_MEDIA_BYTES, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, conversationMediaCacheDir, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, toAnthropicImageBlocksWithinBudget, uploadMediaFile, } from './media.js';
11
13
  export type { AnthropicImageBlock, AnthropicImageBudgetOptions, AnthropicImageMimeType, MaterializeMediaOptions, MaterializedCanonAttachment, MaterializedCanonReplyContext, ReplyWithFileOptions, UploadMediaFileOptions, } from './media.js';
12
14
  export type { SessionConfig, Session } from './session-manager.js';
13
15
  export type { CanonAgentTurnVerbosityOption } from './turn-verbosity-option.js';
@@ -21,3 +23,5 @@ export type { WorkSessionHostStore, WorkSessionHostJournal } from './work-sessio
21
23
  export { createFileAttachedSessionStore } from '@canonmsg/core';
22
24
  export type { CanonAttachedSession, CanonAttachedSessionConnection, CanonAttachedSessionOptions, CanonAttachedSessionStatus, } from './attached-session.js';
23
25
  export type { AttachedNativeSessionAdapter, AttachedSessionBinding, AttachedSessionJournal, AttachedSessionState, AttachedSessionStore, NativeSessionEvent, NativeSessionItem, NativeSessionSnapshot, NativeSubmissionResult, } from '@canonmsg/core';
26
+ export { ARTIFACT_CONTEXT_PROTOCOL, ARTIFACT_LIMITS, CanonArtifactError, isCanonArtifactEnvironmentEnabled, parseArtifactContext, readArtifactContext, } from '@canonmsg/core';
27
+ export type { CanonConversationArtifacts, CanonArtifactErrorCode, ArtifactConflictView, ArtifactDomainOutcome, ArtifactCreateInput, ArtifactUpdateAction, ArtifactUpdateResult, ArtifactWriteView, ArtifactInputValue, ArtifactList, ArtifactListItem, ArtifactGenerateImageInput, ArtifactGenerateImageResult, ArtifactAddImageInput, ArtifactAddImageResult, ArtifactImageRegion, ArtifactAsset, ArtifactView, ArtifactPrincipalView, ArtifactChessView, ArtifactChessMoveView, ArtifactFormView, ArtifactFormFieldView, ArtifactFormChangeView, ArtifactImageView, ArtifactImageVariantView, ArtifactKind, ArtifactFormField, ArtifactFormFieldType, ArtifactFormValue, ArtifactFormTable, ArtifactFormColumn, ArtifactContext, ArtifactReference, ArtifactReferenceOrigin, ArtifactSelection, } from '@canonmsg/core';
package/dist/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  export { CanonAgent } from './canon-agent.js';
2
2
  export { createCanonManagedSessionHost } from './managed-session-host.js';
3
+ export { MANAGED_EXECUTION_MODE_LABELS, buildManagedSessionCatalog, configureManagedSession, findManagedSessionState, hasSavedNativeSession, inspectGitEnvironment, managedSessionFields, observedSessionFields, savedSessionFields, savedSessionMatchesOwner, } from './managed-session-setup.js';
3
4
  export { clearPendingRegistration, ensureAgentProfile, resumeRegistration } from '@canonmsg/core';
4
5
  export { ApprovalManager, buildApprovalOutcome, buildApprovalReply, buildApprovalRequest, CanonApiError, DEFAULT_APPROVAL_CONFIG, generateApprovalId, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, parseApprovalReplyMetadata, parseApprovalRequestMetadata, parseSessionRule, redactSecrets, } from '@canonmsg/core';
5
6
  export { SessionManager } from './session-manager.js';
6
- export { DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES, DEFAULT_MEDIA_CACHE_DIR, DEFAULT_MEDIA_MATERIALIZATION_BYTES, MAX_ANTHROPIC_IMAGE_RAW_BYTES, MAX_ANTHROPIC_REQUEST_BYTES, MAX_CANON_MEDIA_BYTES, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, toAnthropicImageBlocksWithinBudget, uploadMediaFile, } from './media.js';
7
+ export { DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES, DEFAULT_MEDIA_CACHE_DIR, DEFAULT_MEDIA_MATERIALIZATION_BYTES, MAX_ANTHROPIC_IMAGE_RAW_BYTES, MAX_ANTHROPIC_REQUEST_BYTES, MAX_CANON_MEDIA_BYTES, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, conversationMediaCacheDir, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, toAnthropicImageBlocksWithinBudget, uploadMediaFile, } from './media.js';
7
8
  export { createCanonAttachedSession, createCanonAttachedSessionPublisher } from './attached-session.js';
8
9
  export { createCanonWorkSessionHost } from './work-session-host.js';
9
10
  export { createFileWorkSessionHostStore } from './work-session-state.js';
10
11
  export { createFileAttachedSessionStore } from '@canonmsg/core';
12
+ // Shared work (Dev-only preview): `ctx.artifacts` is `CanonClient.artifacts(conversationId)`.
13
+ export { ARTIFACT_CONTEXT_PROTOCOL, ARTIFACT_LIMITS, CanonArtifactError, isCanonArtifactEnvironmentEnabled, parseArtifactContext, readArtifactContext, } from '@canonmsg/core';
@@ -0,0 +1,99 @@
1
+ import { type ExecutionEnvironmentMode, type LocalRuntimeResumeField, type LocalRuntimeSessionState, type WorkSessionCatalog, type WorkSessionChoice, type WorkSessionDetailFields, type WorkSessionModelChoice, type WorkSessionRequest, type WorkSessionSettings } from '@canonmsg/core';
2
+ /**
3
+ * Owner-confirmed ("managed") setup for a coding host whose own message loop
4
+ * runs the turns. The broker side lives in `createCanonManagedSessionHost`;
5
+ * these helpers are the runtime-neutral host side: the catalog it offers, the
6
+ * idempotent `configure`, the saved-state fences and the private details it
7
+ * reports. A runtime supplies its labels, models and permission mapping.
8
+ */
9
+ export interface ManagedSessionProject {
10
+ id: string;
11
+ label: string;
12
+ cwd: string;
13
+ }
14
+ /** How a runtime names itself and its native session in owner-facing errors. */
15
+ export interface ManagedSessionRuntime {
16
+ /** e.g. `Codex`, `Claude Code`. */
17
+ label: string;
18
+ /** e.g. `thread`, `session`. */
19
+ nativeSession: string;
20
+ /** The local runtime catalog field holding the native session id. */
21
+ resumeField: LocalRuntimeResumeField;
22
+ }
23
+ /** Runtime-specific configured facts shown beside the generic settings. */
24
+ export interface ManagedSessionConfiguredFacts {
25
+ sandbox?: string | null;
26
+ approvalPolicy?: string | null;
27
+ nativeThreadBehavior?: string;
28
+ }
29
+ export declare const MANAGED_EXECUTION_MODE_LABELS: Readonly<Record<ExecutionEnvironmentMode, string>>;
30
+ export declare function buildManagedSessionCatalog(input: {
31
+ provider: string;
32
+ projects: readonly ManagedSessionProject[];
33
+ models: readonly WorkSessionModelChoice[];
34
+ defaultModelId?: string;
35
+ defaultReasoningEffort?: string | null;
36
+ /** Catalog-wide thinking levels, for models that list none of their own. */
37
+ reasoningEfforts?: readonly WorkSessionChoice[];
38
+ permissionModes: readonly WorkSessionChoice[];
39
+ defaultPermissionMode: string;
40
+ executionModes: readonly ExecutionEnvironmentMode[];
41
+ defaultExecutionMode: ExecutionEnvironmentMode;
42
+ }): WorkSessionCatalog;
43
+ /** The one saved setup for a conversation, or null; conflicting copies fail closed. */
44
+ export declare function findManagedSessionState(runtimeId: string, conversationId: string, runtime: Pick<ManagedSessionRuntime, 'label'>): LocalRuntimeSessionState | null;
45
+ export declare function hasSavedNativeSession(runtimeId: string, conversationId: string, resumeField: LocalRuntimeResumeField): boolean;
46
+ /** Older records without ownership fields stay usable; a different owner generation does not. */
47
+ export declare function savedSessionMatchesOwner(state: LocalRuntimeSessionState, ownerId: string | null, ownershipVersion: number | undefined): boolean;
48
+ export declare function inspectGitEnvironment(cwd: string): Pick<WorkSessionDetailFields, 'branch' | 'worktreePath'>;
49
+ /** Private owner details for a running session: confirmed settings, then what the runtime and Git observe. */
50
+ export declare function managedSessionFields(input: {
51
+ settings: WorkSessionSettings;
52
+ cwd: string;
53
+ projectLabel?: string;
54
+ configured?: ManagedSessionConfiguredFacts;
55
+ observedModel?: string;
56
+ observedEffort?: string;
57
+ }): WorkSessionDetailFields;
58
+ /** Private details for a running session nobody set up: what the host and Git observe. */
59
+ export declare function observedSessionFields(input: {
60
+ cwd: string;
61
+ executionMode?: string;
62
+ project?: {
63
+ id: string;
64
+ label: string;
65
+ };
66
+ observedModel?: string;
67
+ observedEffort?: string;
68
+ }): WorkSessionDetailFields;
69
+ /** Read only: details for a saved session with no live native process, if its directory still exists. */
70
+ export declare function savedSessionFields(input: {
71
+ state: LocalRuntimeSessionState;
72
+ agentId: string;
73
+ projects: readonly ManagedSessionProject[];
74
+ describeConfigured?: (settings: WorkSessionSettings) => ManagedSessionConfiguredFacts;
75
+ }): WorkSessionDetailFields;
76
+ /**
77
+ * Idempotent configuration for `createCanonManagedSessionHost`. Validates the
78
+ * owner's selection against the catalog, prepares the requested environment
79
+ * (a requested worktree that cannot be created fails instead of falling back)
80
+ * and saves the setup before the broker is acknowledged. A replayed request
81
+ * returns the saved setup; a conversation that already has a native session or
82
+ * a different confirmed setup keeps it.
83
+ */
84
+ export declare function configureManagedSession(input: {
85
+ request: WorkSessionRequest;
86
+ runtimeId: string;
87
+ agentId: string;
88
+ catalog: WorkSessionCatalog;
89
+ projects: readonly ManagedSessionProject[];
90
+ runtime: ManagedSessionRuntime;
91
+ ownerId?: string;
92
+ ownershipVersion?: number;
93
+ /** False when the runtime cannot apply a catalog permission mode with the selected model. */
94
+ isPermissionModeApplicable?: (permissionMode: string, modelId: string | undefined) => boolean;
95
+ describeConfigured?: (settings: WorkSessionSettings) => ManagedSessionConfiguredFacts;
96
+ }): {
97
+ settings: WorkSessionSettings;
98
+ fields: WorkSessionDetailFields;
99
+ };
@@ -0,0 +1,234 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { existsSync, realpathSync } from 'node:fs';
4
+ import { join, relative } from 'node:path';
5
+ import { buildConversationWorktreeSpec, prepareConversationEnvironment, releaseConversationEnvironment, readLocalRuntimeEntry, saveRuntimeSessionState, } from '@canonmsg/core';
6
+ const SETTING_KEYS = ['projectId', 'modelId', 'reasoningEffort', 'permissionMode', 'executionMode'];
7
+ export const MANAGED_EXECUTION_MODE_LABELS = {
8
+ worktree: 'Git worktree',
9
+ locked: 'Project folder',
10
+ };
11
+ export function buildManagedSessionCatalog(input) {
12
+ const defaults = {
13
+ ...(input.projects[0] ? { projectId: input.projects[0].id } : {}),
14
+ ...(input.defaultModelId ? { modelId: input.defaultModelId } : {}),
15
+ ...(input.defaultReasoningEffort ? { reasoningEffort: input.defaultReasoningEffort } : {}),
16
+ permissionMode: input.defaultPermissionMode, executionMode: input.defaultExecutionMode,
17
+ };
18
+ const catalog = {
19
+ provider: input.provider, canCreate: false, canAttach: false, managedSetup: { version: 1 },
20
+ projects: input.projects.map((project) => ({ id: project.id, label: project.label })),
21
+ models: input.models.map((model) => ({ ...model })),
22
+ ...(input.reasoningEfforts?.length ? { reasoningEfforts: input.reasoningEfforts.map((effort) => ({ ...effort })) } : {}),
23
+ permissionModes: input.permissionModes.map((mode) => ({ ...mode })),
24
+ executionModes: input.executionModes.map((id) => ({ id, label: MANAGED_EXECUTION_MODE_LABELS[id] })),
25
+ defaults, sessions: [],
26
+ };
27
+ return { revision: createHash('sha256').update(JSON.stringify(catalog)).digest('hex').slice(0, 24), ...catalog };
28
+ }
29
+ /** The one saved setup for a conversation, or null; conflicting copies fail closed. */
30
+ export function findManagedSessionState(runtimeId, conversationId, runtime) {
31
+ const states = Object.values(readLocalRuntimeEntry(runtimeId)?.sessions ?? {})
32
+ .filter((state) => state.conversationId === conversationId && state.codexManagedSetup);
33
+ if (states.length > 1)
34
+ throw new Error(`Conflicting saved ${runtime.label} setup for this conversation.`);
35
+ return states[0] ?? null;
36
+ }
37
+ export function hasSavedNativeSession(runtimeId, conversationId, resumeField) {
38
+ return Object.values(readLocalRuntimeEntry(runtimeId)?.sessions ?? {})
39
+ .some((state) => state.conversationId === conversationId && Boolean(state[resumeField]));
40
+ }
41
+ /** Older records without ownership fields stay usable; a different owner generation does not. */
42
+ export function savedSessionMatchesOwner(state, ownerId, ownershipVersion) {
43
+ return (state.ownerId === undefined || ownerId === null || state.ownerId === ownerId)
44
+ && (state.ownershipVersion === undefined || ownershipVersion === undefined
45
+ || state.ownershipVersion === ownershipVersion);
46
+ }
47
+ export function inspectGitEnvironment(cwd) {
48
+ const fields = {};
49
+ try {
50
+ const branch = execFileSync('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd, encoding: 'utf8', timeout: 3_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
51
+ if (branch)
52
+ fields.branch = { value: branch, provenance: 'observed' };
53
+ }
54
+ catch { /* Detached HEAD has no branch to report. */ }
55
+ try {
56
+ const [gitDir, commonDir] = execFileSync('git', ['rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir'], { cwd, encoding: 'utf8', timeout: 3_000, stdio: ['ignore', 'pipe', 'ignore'] })
57
+ .trim().split(/\r?\n/);
58
+ if (gitDir && commonDir && realpathSync(gitDir) !== realpathSync(commonDir)) {
59
+ const path = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', timeout: 3_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
60
+ if (path)
61
+ fields.worktreePath = { value: realpathSync(path), provenance: 'observed' };
62
+ }
63
+ }
64
+ catch { /* An unavailable Git path has no worktree observation. */ }
65
+ return fields;
66
+ }
67
+ function applyConfiguredFacts(fields, facts) {
68
+ if (facts?.sandbox)
69
+ fields.sandbox = { value: facts.sandbox, provenance: 'configured' };
70
+ if (facts?.approvalPolicy)
71
+ fields.approvalPolicy = { value: facts.approvalPolicy, provenance: 'configured' };
72
+ if (facts?.nativeThreadBehavior) {
73
+ fields.nativeThreadBehavior = { value: facts.nativeThreadBehavior, provenance: 'configured' };
74
+ }
75
+ }
76
+ /** Private owner details for a running session: confirmed settings, then what the runtime and Git observe. */
77
+ export function managedSessionFields(input) {
78
+ const fields = {};
79
+ for (const key of SETTING_KEYS) {
80
+ if (input.settings[key])
81
+ fields[key] = { value: input.settings[key], provenance: 'configured' };
82
+ }
83
+ if (input.projectLabel)
84
+ fields.projectLabel = { value: input.projectLabel, provenance: 'configured' };
85
+ fields.workingDirectory = { value: realpathSync(input.cwd), provenance: 'observed' };
86
+ applyConfiguredFacts(fields, input.configured);
87
+ if (input.observedModel)
88
+ fields.modelId = { value: input.observedModel, provenance: 'observed' };
89
+ if (input.observedEffort)
90
+ fields.reasoningEffort = { value: input.observedEffort, provenance: 'observed' };
91
+ Object.assign(fields, inspectGitEnvironment(input.cwd));
92
+ return fields;
93
+ }
94
+ /** Private details for a running session nobody set up: what the host and Git observe. */
95
+ export function observedSessionFields(input) {
96
+ const fields = { workingDirectory: { value: input.cwd, provenance: 'observed' } };
97
+ if (input.executionMode)
98
+ fields.executionMode = { value: input.executionMode, provenance: 'observed' };
99
+ if (input.project) {
100
+ fields.projectId = { value: input.project.id, provenance: 'observed' };
101
+ fields.projectLabel = { value: input.project.label, provenance: 'observed' };
102
+ }
103
+ if (input.observedModel)
104
+ fields.modelId = { value: input.observedModel, provenance: 'observed' };
105
+ if (input.observedEffort)
106
+ fields.reasoningEffort = { value: input.observedEffort, provenance: 'observed' };
107
+ Object.assign(fields, inspectGitEnvironment(input.cwd));
108
+ return fields;
109
+ }
110
+ /** Read only: details for a saved session with no live native process, if its directory still exists. */
111
+ export function savedSessionFields(input) {
112
+ const { state } = input;
113
+ const configured = state.codexManagedSetup?.settings;
114
+ const project = input.projects.find((entry) => entry.cwd === state.baseCwd);
115
+ let cwd;
116
+ if (state.executionMode === 'locked' && existsSync(state.baseCwd))
117
+ cwd = state.baseCwd;
118
+ if (state.executionMode === 'worktree') {
119
+ try {
120
+ const spec = buildConversationWorktreeSpec({ agentId: input.agentId,
121
+ conversationId: state.conversationId, workspaceCwd: state.baseCwd });
122
+ const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: state.baseCwd, encoding: 'utf8', timeout: 3_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
123
+ const candidate = join(spec.worktreePath, relative(root, state.baseCwd));
124
+ if (existsSync(candidate))
125
+ cwd = candidate;
126
+ }
127
+ catch { /* Unavailable path stays unreported. */ }
128
+ }
129
+ const facts = configured ? input.describeConfigured?.(configured) : undefined;
130
+ if (configured && cwd) {
131
+ return managedSessionFields({ settings: configured, cwd, projectLabel: project?.label, configured: facts });
132
+ }
133
+ const fields = {};
134
+ if (configured) {
135
+ for (const key of SETTING_KEYS) {
136
+ if (configured[key])
137
+ fields[key] = { value: configured[key], provenance: 'configured' };
138
+ }
139
+ if (project?.label)
140
+ fields.projectLabel = { value: project.label, provenance: 'configured' };
141
+ applyConfiguredFacts(fields, facts);
142
+ }
143
+ else {
144
+ if (state.executionMode)
145
+ fields.executionMode = { value: state.executionMode, provenance: 'observed' };
146
+ if (project) {
147
+ fields.projectId = { value: project.id, provenance: 'observed' };
148
+ fields.projectLabel = { value: project.label, provenance: 'observed' };
149
+ }
150
+ }
151
+ if (cwd) {
152
+ fields.workingDirectory = { value: realpathSync(cwd), provenance: 'observed' };
153
+ Object.assign(fields, inspectGitEnvironment(cwd));
154
+ }
155
+ return fields;
156
+ }
157
+ /**
158
+ * Idempotent configuration for `createCanonManagedSessionHost`. Validates the
159
+ * owner's selection against the catalog, prepares the requested environment
160
+ * (a requested worktree that cannot be created fails instead of falling back)
161
+ * and saves the setup before the broker is acknowledged. A replayed request
162
+ * returns the saved setup; a conversation that already has a native session or
163
+ * a different confirmed setup keeps it.
164
+ */
165
+ export function configureManagedSession(input) {
166
+ const { runtime } = input;
167
+ const selection = input.request.selection;
168
+ if (selection.mode !== 'configure' || input.catalog.managedSetup?.version !== 1)
169
+ throw new Error('This host does not support the requested setup.');
170
+ const existing = findManagedSessionState(input.runtimeId, input.request.conversationId, runtime);
171
+ if (existing?.codexManagedSetup) {
172
+ if (!savedSessionMatchesOwner(existing, input.ownerId ?? null, input.ownershipVersion))
173
+ throw new Error(`The saved ${runtime.label} setup belongs to a previous owner generation.`);
174
+ if (existing.codexManagedSetup.requestId !== input.request.requestId)
175
+ throw new Error(`This conversation already has a confirmed ${runtime.label} setup.`);
176
+ const settings = existing.codexManagedSetup.settings;
177
+ const environment = prepareConversationEnvironment({ agentId: input.agentId,
178
+ conversationId: input.request.conversationId, workspaceCwd: existing.baseCwd,
179
+ allowWorktrees: existing.executionMode === 'worktree' });
180
+ try {
181
+ if (environment.mode !== existing.executionMode)
182
+ throw new Error(`The saved ${runtime.label} execution environment is unavailable.`);
183
+ return { settings,
184
+ fields: managedSessionFields({ settings, cwd: environment.cwd,
185
+ projectLabel: input.projects.find((project) => project.id === settings.projectId)?.label,
186
+ configured: input.describeConfigured?.(settings) }),
187
+ };
188
+ }
189
+ finally {
190
+ releaseConversationEnvironment(environment);
191
+ }
192
+ }
193
+ if (hasSavedNativeSession(input.runtimeId, input.request.conversationId, runtime.resumeField))
194
+ throw new Error(`This conversation already has a native ${runtime.label} ${runtime.nativeSession}. Its settings are preserved.`);
195
+ const project = input.projects.find((entry) => entry.id === selection.projectId);
196
+ if (!project)
197
+ throw new Error('The selected project is unavailable on this host.');
198
+ const modelId = selection.modelId ?? input.catalog.defaults?.modelId;
199
+ const model = input.catalog.models?.find((entry) => entry.id === modelId);
200
+ if (modelId && !model)
201
+ throw new Error('The selected model is unavailable on this host.');
202
+ const reasoningEffort = selection.reasoningEffort ?? input.catalog.defaults?.reasoningEffort;
203
+ if (reasoningEffort && !(model?.reasoningEfforts?.length ? model.reasoningEfforts : input.catalog.reasoningEfforts ?? [])
204
+ .some((entry) => entry.id === reasoningEffort))
205
+ throw new Error('The selected thinking level is unavailable for this model.');
206
+ const permissionMode = selection.permissionMode ?? input.catalog.defaults?.permissionMode;
207
+ const executionMode = selection.executionMode ?? input.catalog.defaults?.executionMode;
208
+ if (!permissionMode || !input.catalog.permissionModes?.some((entry) => entry.id === permissionMode)
209
+ || !executionMode || !input.catalog.executionModes?.some((entry) => entry.id === executionMode))
210
+ throw new Error('The selected execution policy is unavailable on this host.');
211
+ if (input.isPermissionModeApplicable && !input.isPermissionModeApplicable(permissionMode, modelId))
212
+ throw new Error('The selected permission policy cannot be applied.');
213
+ const environment = prepareConversationEnvironment({ agentId: input.agentId,
214
+ conversationId: input.request.conversationId, workspaceCwd: project.cwd,
215
+ allowWorktrees: executionMode === 'worktree' });
216
+ try {
217
+ if (environment.mode !== executionMode)
218
+ throw new Error('The requested Git worktree could not be created. Select Project folder explicitly to use the base project.');
219
+ const settings = { projectId: project.id,
220
+ ...(modelId ? { modelId } : {}), ...(reasoningEffort ? { reasoningEffort } : {}),
221
+ permissionMode, executionMode };
222
+ const fields = managedSessionFields({ settings, cwd: environment.cwd, projectLabel: project.label,
223
+ configured: input.describeConfigured?.(settings) });
224
+ saveRuntimeSessionState(input.runtimeId, { conversationId: input.request.conversationId,
225
+ baseCwd: environment.baseCwd, executionMode: executionMode,
226
+ ...(input.ownerId ? { ownerId: input.ownerId } : {}),
227
+ ...(input.ownershipVersion !== undefined ? { ownershipVersion: input.ownershipVersion } : {}),
228
+ codexManagedSetup: { requestId: input.request.requestId, settings } });
229
+ return { settings, fields };
230
+ }
231
+ finally {
232
+ releaseConversationEnvironment(environment);
233
+ }
234
+ }
package/dist/media.d.ts CHANGED
@@ -82,6 +82,17 @@ export declare const MAX_CANON_MEDIA_BYTES: number;
82
82
  export declare const MAX_ANTHROPIC_IMAGE_RAW_BYTES: number;
83
83
  export declare const MAX_ANTHROPIC_REQUEST_BYTES = 32000000;
84
84
  export declare const DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES: number;
85
+ /**
86
+ * The directory every materialized attachment of one conversation lands in
87
+ * (`<root>/<agent>/<conversation>/<message>/…`). A host that runs one native
88
+ * session per conversation can grant its runtime read access to exactly this
89
+ * directory instead of approving each downloaded file.
90
+ */
91
+ export declare function conversationMediaCacheDir(input: {
92
+ agentId: string;
93
+ conversationId: string;
94
+ rootDir?: string;
95
+ }): string;
85
96
  export declare function getMessageAttachments(message: Pick<CanonMessage, 'attachments'>): MediaAttachment[];
86
97
  export declare function materializeAttachment(attachment: MediaAttachment, options: MaterializeMediaOptions & {
87
98
  index?: number;
package/dist/media.js CHANGED
@@ -82,6 +82,15 @@ function inferExtension(input) {
82
82
  }
83
83
  return '.bin';
84
84
  }
85
+ /**
86
+ * The directory every materialized attachment of one conversation lands in
87
+ * (`<root>/<agent>/<conversation>/<message>/…`). A host that runs one native
88
+ * session per conversation can grant its runtime read access to exactly this
89
+ * directory instead of approving each downloaded file.
90
+ */
91
+ export function conversationMediaCacheDir(input) {
92
+ return join(input.rootDir ?? DEFAULT_MEDIA_CACHE_DIR, sanitizeSegment(input.agentId, 'agent'), sanitizeSegment(input.conversationId, 'conversation'));
93
+ }
85
94
  function buildCachePath(input) {
86
95
  const rootDir = input.rootDir ?? DEFAULT_MEDIA_CACHE_DIR;
87
96
  const extension = inferExtension({
@@ -93,7 +102,7 @@ function buildCachePath(input) {
93
102
  ? sanitizeDownloadFileName(input.attachment.fileName, fallbackName)
94
103
  : fallbackName;
95
104
  const fileName = extname(preferredName) ? preferredName : `${preferredName}${extension}`;
96
- return join(rootDir, sanitizeSegment(input.agentId, 'agent'), sanitizeSegment(input.conversationId, 'conversation'), sanitizeSegment(input.messageId, 'message'), `${String(input.index).padStart(2, '0')}-${fileName}`);
105
+ return join(conversationMediaCacheDir({ agentId: input.agentId, conversationId: input.conversationId, rootDir }), sanitizeSegment(input.messageId, 'message'), `${String(input.index).padStart(2, '0')}-${fileName}`);
97
106
  }
98
107
  function ensureFetch(fetchImpl) {
99
108
  if (fetchImpl)
package/dist/types.d.ts CHANGED
@@ -207,6 +207,26 @@ export interface MessageHandlerContext {
207
207
  provenance: CanonRuntimeProvenance;
208
208
  /** Compact structured Canon turn context for the latest inbound message in this handler batch. */
209
209
  turnContext: CanonTurnContextV2;
210
+ /**
211
+ * Each batched input's own shared-work references (the version it was
212
+ * about, `origin` and any selection), in batch order. Malformed references
213
+ * are dropped; an input with none is omitted.
214
+ */
215
+ artifactContexts: Array<{
216
+ messageId: string;
217
+ context: import('@canonmsg/core').ArtifactContext;
218
+ }>;
219
+ /**
220
+ * Shared work in this handler's conversation (chess, forms, images): exactly
221
+ * `CanonClient.artifacts(conversationId)`. Changes are made against the state
222
+ * this agent last read or wrote (across turns), so a person's newer edit is
223
+ * reported as a `CanonArtifactError` conflict instead of being overwritten:
224
+ * `describe()` before `update()` (or pass `basedOnRevisionId`); with no read
225
+ * yet, `update()` rejects with `NOT_READ`. Arrival reads nothing on the
226
+ * handler's behalf. Dev-only preview; other environments reject with
227
+ * `ARTIFACTS_UNAVAILABLE`.
228
+ */
229
+ readonly artifacts: import('@canonmsg/core').CanonConversationArtifacts;
210
230
  /** Runtime turn mode requested by the sender for this inbound turn, if any. */
211
231
  requestedTurnMode: string | null;
212
232
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "11.1.1",
3
+ "version": "11.2.0",
4
4
  "description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "node": ">=22.22.3"
29
29
  },
30
30
  "dependencies": {
31
- "@canonmsg/core": "^13.2.0"
31
+ "@canonmsg/core": "^13.3.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",