synorch 0.4.0-beta.0 → 0.4.0-beta.1

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 (32) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +2 -2
  3. package/dist/build-info.json +2 -2
  4. package/dist/domain/product.d.ts +1 -1
  5. package/dist/domain/product.js +1 -1
  6. package/dist/harness/auth/json-file.d.ts +8 -0
  7. package/dist/harness/auth/json-file.d.ts.map +1 -1
  8. package/dist/harness/auth/json-file.js +17 -6
  9. package/dist/harness/auth/json-file.js.map +1 -1
  10. package/dist/harness/auth/locks.d.ts +5 -0
  11. package/dist/harness/auth/locks.d.ts.map +1 -1
  12. package/dist/harness/auth/locks.js +51 -22
  13. package/dist/harness/auth/locks.js.map +1 -1
  14. package/dist/harness/cli/conversation.d.ts.map +1 -1
  15. package/dist/harness/cli/conversation.js +106 -13
  16. package/dist/harness/cli/conversation.js.map +1 -1
  17. package/dist/harness/cli/first-run-connect.d.ts +43 -0
  18. package/dist/harness/cli/first-run-connect.d.ts.map +1 -0
  19. package/dist/harness/cli/first-run-connect.js +144 -0
  20. package/dist/harness/cli/first-run-connect.js.map +1 -0
  21. package/dist/harness/cli/runtime.d.ts.map +1 -1
  22. package/dist/harness/cli/runtime.js +2 -0
  23. package/dist/harness/cli/runtime.js.map +1 -1
  24. package/dist/harness/contracts/renderer.d.ts +2 -0
  25. package/dist/harness/contracts/renderer.d.ts.map +1 -1
  26. package/dist/harness/contracts/renderer.js.map +1 -1
  27. package/dist/harness/tui/pi-tui-renderer.d.ts.map +1 -1
  28. package/dist/harness/tui/pi-tui-renderer.js +1 -0
  29. package/dist/harness/tui/pi-tui-renderer.js.map +1 -1
  30. package/dist/infrastructure/build-info.d.ts +1 -1
  31. package/dist/infrastructure/build-info.js +1 -1
  32. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0-beta.1] - 2026-09-26
11
+
12
+ Pre-release under the npm dist-tag `beta`. `latest` stays on 0.3.0.
13
+
14
+ ### Fixed
15
+
16
+ - First run on a new machine: bare `syn` asks which provider to connect, signs in, saves `routes.session` and continues, instead of exiting with `config_invalid` before any sign-in was possible. Headless runs without a route name the two commands to run.
17
+ - A turn that fails because the provider is not signed in offers to sign in in the session and retry.
18
+ - A Claude Code sign-in made inside a session is visible to it immediately.
19
+ - Windows: the credential refresh lock tolerates `EPERM`/`EBUSY`.
20
+
10
21
  ## [0.4.0-beta.0] - 2026-09-26
11
22
 
12
23
  Pre-release, published under the npm dist-tag `beta` (`npm i -g synorch@beta`). `latest` stays on 0.3.0.
@@ -88,7 +99,8 @@ Pre-release, published under the npm dist-tag `beta` (`npm i -g synorch@beta`).
88
99
  - A bundled, provenance-tracked Ingenium skill catalog with on-demand loading rules.
89
100
  - Model profiles, orchestration protocols, minimal context packets, verification contracts, and provider adapters.
90
101
 
91
- [Unreleased]: https://github.com/TunahanTuna/synorch/compare/v0.4.0-beta.0...HEAD
102
+ [Unreleased]: https://github.com/TunahanTuna/synorch/compare/v0.4.0-beta.1...HEAD
103
+ [0.4.0-beta.1]: https://github.com/TunahanTuna/synorch/compare/v0.4.0-beta.0...v0.4.0-beta.1
92
104
  [0.4.0-beta.0]: https://github.com/TunahanTuna/synorch/compare/v0.3.0...v0.4.0-beta.0
93
105
  [0.3.0]: https://github.com/TunahanTuna/synorch/compare/v0.2.0...v0.3.0
94
106
  [0.2.0]: https://github.com/TunahanTuna/synorch/compare/v0.1.0...v0.2.0
package/README.md CHANGED
@@ -70,7 +70,7 @@ Installing from a local checkout (works on Windows, macOS and Linux; no `syn ini
70
70
  ```bash
71
71
  pnpm install
72
72
  pnpm run install:global # build, pack and install a self-contained global copy
73
- syn --version # 0.4.0-beta.0 (<commit>)
73
+ syn --version # 0.4.0-beta.1 (<commit>)
74
74
  ```
75
75
 
76
76
  See [docs/harness/delivery/install.md](./docs/harness/delivery/install.md) for the linked (live-rebuild) variant, updating and uninstalling.
@@ -353,7 +353,7 @@ pnpm dev -- inspect # run the CLI from source
353
353
  | [Architecture](./docs/AI-ORCHESTRATION-ARCHITECTURE.md) | The full design: orchestration model, protocol system, context architecture, model routing, discovery, open decisions, and the dated decision log. |
354
354
  | [Multi-provider harness vision](./docs/FUTURE-MULTI-PROVIDER-HARNESS.md) | Where this goes next — running different providers in different roles under one orchestrator. Proposal stage, not implemented. |
355
355
  | [CHANGELOG](./CHANGELOG.md) | Version history, Keep a Changelog + SemVer. |
356
- | [Release notes v0.4.0-beta.0](./docs/releases/v0.4.0-beta.0.md) | Human-readable notes for the current beta (`synorch@beta`). |
356
+ | [Release notes v0.4.0-beta.1](./docs/releases/v0.4.0-beta.1.md) | Human-readable notes for the current beta (`synorch@beta`). |
357
357
  | [Release notes v0.3.0](./docs/releases/v0.3.0.md) | Human-readable notes for the current stable release. |
358
358
  | [Security policy](./SECURITY.md) | Report vulnerabilities through the private channel, not a public issue. |
359
359
 
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "1968e9f",
3
- "builtAt": "2026-09-25T21:44:15.851Z"
2
+ "commit": "12ee7ff",
3
+ "builtAt": "2026-09-26T00:00:40.891Z"
4
4
  }
@@ -1,3 +1,3 @@
1
1
  export declare const SYNORCH_GENERATOR_NAME: "synorch";
2
- export declare const SYNORCH_VERSION: "0.4.0-beta.0";
2
+ export declare const SYNORCH_VERSION: "0.4.0-beta.1";
3
3
  //# sourceMappingURL=product.d.ts.map
@@ -1,3 +1,3 @@
1
1
  export const SYNORCH_GENERATOR_NAME = "synorch";
2
- export const SYNORCH_VERSION = "0.4.0-beta.0";
2
+ export const SYNORCH_VERSION = "0.4.0-beta.1";
3
3
  //# sourceMappingURL=product.js.map
@@ -5,4 +5,12 @@ export declare function readJsonFile(filePath: string): Promise<unknown>;
5
5
  * Windows the mode bits are ignored and the file inherits the user profile's ACL.
6
6
  */
7
7
  export declare function writeJsonAtomic(filePath: string, value: unknown): Promise<void>;
8
+ /**
9
+ * Windows reports EPERM/EACCES/EBUSY while another handle (a concurrent reader, antivirus, the
10
+ * indexer) holds a file, or while it is still delete-pending after an unlink. Those clear within
11
+ * milliseconds, so the operation is retried with a bounded exponential backoff (about 1.3s in
12
+ * total at the default 8 attempts) before the error is surfaced.
13
+ */
14
+ export declare function retryTransientFs<T>(operation: () => Promise<T>, attempts?: number): Promise<T>;
15
+ export declare function isTransientFsError(error: unknown): boolean;
8
16
  //# sourceMappingURL=json-file.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"json-file.d.ts","sourceRoot":"","sources":["../../../src/harness/auth/json-file.ts"],"names":[],"mappings":"AAIA,uHAAuH;AACvH,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CASrE;AAED;;;GAGG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAkBrF"}
1
+ {"version":3,"file":"json-file.d.ts","sourceRoot":"","sources":["../../../src/harness/auth/json-file.ts"],"names":[],"mappings":"AAIA,uHAAuH;AACvH,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CASrE;AAED;;;GAGG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAkBrF;AAQD;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,QAAQ,SAAI,GAAG,OAAO,CAAC,CAAC,CAAC,CAS/F;AAED,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAE1D"}
@@ -41,17 +41,28 @@ export async function writeJsonAtomic(filePath, value) {
41
41
  await chmod(filePath, 0o600);
42
42
  }
43
43
  async function renameWithRetry(from, to) {
44
- for (let attempt = 0;; attempt += 1) {
44
+ await retryTransientFs(() => rename(from, to));
45
+ }
46
+ const TRANSIENT_FS_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
47
+ /**
48
+ * Windows reports EPERM/EACCES/EBUSY while another handle (a concurrent reader, antivirus, the
49
+ * indexer) holds a file, or while it is still delete-pending after an unlink. Those clear within
50
+ * milliseconds, so the operation is retried with a bounded exponential backoff (about 1.3s in
51
+ * total at the default 8 attempts) before the error is surfaced.
52
+ */
53
+ export async function retryTransientFs(operation, attempts = 8) {
54
+ for (let attempt = 1;; attempt += 1) {
45
55
  try {
46
- await rename(from, to);
47
- return;
56
+ return await operation();
48
57
  }
49
58
  catch (error) {
50
- const code = error.code;
51
- if (attempt >= 10 || (code !== "EPERM" && code !== "EACCES" && code !== "EBUSY"))
59
+ if (attempt >= attempts || !isTransientFsError(error))
52
60
  throw error;
53
- await new Promise((resolve) => setTimeout(resolve, 20 * (attempt + 1)));
61
+ await new Promise((resolve) => setTimeout(resolve, 5 * 2 ** attempt));
54
62
  }
55
63
  }
56
64
  }
65
+ export function isTransientFsError(error) {
66
+ return TRANSIENT_FS_CODES.has(error?.code ?? "");
67
+ }
57
68
  //# sourceMappingURL=json-file.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"json-file.js","sourceRoot":"","sources":["../../../src/harness/auth/json-file.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"json-file.js","sourceRoot":"","sources":["../../../src/harness/auth/json-file.ts"],"names":[],"mappings":"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"}
@@ -9,6 +9,11 @@ export interface FileLockOptions {
9
9
  * Cross-process mutual exclusion through an `O_EXCL` lock file holding the owner's pid and a random
10
10
  * token. Release removes the file only if it still carries our token. A lock whose holder process
11
11
  * is gone, or which is older than `staleMs`, is taken over.
12
+ *
13
+ * On Windows a just-released lock file stays delete-pending while any other handle (a waiter's
14
+ * stale check, antivirus, the indexer) is still open on it; during that window `open(wx)`, `unlink`
15
+ * and reads fail with EPERM/EACCES/EBUSY. On `open` that means "someone is on it" and is treated
16
+ * like EEXIST (keep polling); `unlink` and the token read are retried with a bounded backoff.
12
17
  */
13
18
  export declare function acquireFileLock(lockPath: string, options?: FileLockOptions): Promise<() => Promise<void>>;
14
19
  /** In-process, per-key serialization: callers for the same key run one after another. */
@@ -1 +1 @@
1
- {"version":3,"file":"locks.d.ts","sourceRoot":"","sources":["../../../src/harness/auth/locks.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,eAAe;IAC9B,sFAAsF;IACtF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AAMD;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CA0CnH;AA0BD,yFAAyF;AACzF,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAuC;IAEhD,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;CAWrE"}
1
+ {"version":3,"file":"locks.d.ts","sourceRoot":"","sources":["../../../src/harness/auth/locks.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,eAAe;IAC9B,sFAAsF;IACtF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AAMD;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAgCnH;AA0DD,yFAAyF;AACzF,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAuC;IAEhD,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;CAWrE"}
@@ -1,7 +1,8 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { mkdir, open, readFile, rm, stat } from "node:fs/promises";
2
+ import { mkdir, open, readFile, stat, unlink } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { HarnessError } from "../contracts/index.js";
5
+ import { isTransientFsError, retryTransientFs } from "./json-file.js";
5
6
  const DEFAULT_STALE_MS = 60_000;
6
7
  const DEFAULT_TIMEOUT_MS = 90_000;
7
8
  const DEFAULT_POLL_MS = 25;
@@ -9,6 +10,11 @@ const DEFAULT_POLL_MS = 25;
9
10
  * Cross-process mutual exclusion through an `O_EXCL` lock file holding the owner's pid and a random
10
11
  * token. Release removes the file only if it still carries our token. A lock whose holder process
11
12
  * is gone, or which is older than `staleMs`, is taken over.
13
+ *
14
+ * On Windows a just-released lock file stays delete-pending while any other handle (a waiter's
15
+ * stale check, antivirus, the indexer) is still open on it; during that window `open(wx)`, `unlink`
16
+ * and reads fail with EPERM/EACCES/EBUSY. On `open` that means "someone is on it" and is treated
17
+ * like EEXIST (keep polling); `unlink` and the token read are retried with a bounded backoff.
12
18
  */
13
19
  export async function acquireFileLock(lockPath, options = {}) {
14
20
  const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
@@ -19,35 +25,24 @@ export async function acquireFileLock(lockPath, options = {}) {
19
25
  if (options.signal?.aborted === true) {
20
26
  throw new HarnessError({ code: "cancelled", message: "lock wait aborted", workspace_effect: "none", retry_safe: true });
21
27
  }
28
+ let transient;
22
29
  try {
23
- const handle = await open(lockPath, "wx", 0o600);
24
- try {
25
- await handle.writeFile(JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() }));
26
- await handle.sync();
27
- }
28
- finally {
29
- await handle.close();
30
- }
31
- return async () => {
32
- try {
33
- const current = JSON.parse(await readFile(lockPath, "utf8"));
34
- if (current.token === token)
35
- await rm(lockPath, { force: true });
36
- }
37
- catch {
38
- return;
39
- }
40
- };
30
+ await createLockFile(lockPath, token);
31
+ return () => releaseFileLock(lockPath, token);
41
32
  }
42
33
  catch (error) {
43
- if (!isExists(error))
34
+ if (isTransientFsError(error))
35
+ transient = error;
36
+ else if (!isExists(error))
44
37
  throw error;
45
38
  }
46
- if (await isAbandoned(lockPath, staleMs)) {
47
- await rm(lockPath, { force: true }).catch(() => undefined);
39
+ if (transient === undefined && (await isAbandoned(lockPath, staleMs))) {
40
+ await removeLockFile(lockPath).catch(() => undefined);
48
41
  continue;
49
42
  }
50
43
  if (Date.now() > deadline) {
44
+ if (transient !== undefined)
45
+ throw transient;
51
46
  throw new HarnessError({
52
47
  code: "internal",
53
48
  message: `timed out waiting for lock ${path.basename(lockPath)}`,
@@ -58,6 +53,40 @@ export async function acquireFileLock(lockPath, options = {}) {
58
53
  await new Promise((resolve) => setTimeout(resolve, options.pollMs ?? DEFAULT_POLL_MS));
59
54
  }
60
55
  }
56
+ /** Creates the lock exclusively; a failed write never leaves a half-written lock file behind. */
57
+ async function createLockFile(lockPath, token) {
58
+ const handle = await open(lockPath, "wx", 0o600);
59
+ try {
60
+ await handle.writeFile(JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() }));
61
+ await handle.sync();
62
+ }
63
+ catch (error) {
64
+ await handle.close().catch(() => undefined);
65
+ await removeLockFile(lockPath).catch(() => undefined);
66
+ throw error;
67
+ }
68
+ await handle.close();
69
+ }
70
+ /** Removes the lock only while it still carries our token. Never throws: release runs in `finally` blocks. */
71
+ async function releaseFileLock(lockPath, token) {
72
+ try {
73
+ const current = JSON.parse(await retryTransientFs(() => readFile(lockPath, "utf8")));
74
+ if (current.token === token)
75
+ await removeLockFile(lockPath, 10);
76
+ }
77
+ catch {
78
+ return;
79
+ }
80
+ }
81
+ async function removeLockFile(lockPath, attempts) {
82
+ try {
83
+ await retryTransientFs(() => unlink(lockPath), attempts);
84
+ }
85
+ catch (error) {
86
+ if (error.code !== "ENOENT")
87
+ throw error;
88
+ }
89
+ }
61
90
  async function isAbandoned(lockPath, staleMs) {
62
91
  try {
63
92
  const [info, text] = await Promise.all([stat(lockPath), readFile(lockPath, "utf8")]);
@@ -1 +1 @@
1
- {"version":3,"file":"locks.js","sourceRoot":"","sources":["../../../src/harness/auth/locks.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"locks.js","sourceRoot":"","sources":["../../../src/harness/auth/locks.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"conversation.d.ts","sourceRoot":"","sources":["../../../src/harness/cli/conversation.ts"],"names":[],"mappings":"AAKA,OAAO,EA0BL,KAAK,eAAe,EAsBrB,MAAM,uBAAuB,CAAC;AAwB/B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AA0B/C,OAAO,EAA+B,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAClF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAsB9C;;;;;;;;;;;;;;;;;GAiBG;AAEH,KAAK,YAAY,GAAG,OAAO,CAAC,aAAa,EAAE;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC,CAAC;AAyG9D,4GAA4G;AAC5G,wBAAgB,cAAc,CAAC,IAAI,EAAE,eAAe,GAAG,eAAe,CAMrE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,eAAe,GAAG,eAAe,CAMpE;AA2ED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,YAAY,EAAE,EAAE,EAAE,SAAS,EAAE,SAAS,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAE3H"}
1
+ {"version":3,"file":"conversation.d.ts","sourceRoot":"","sources":["../../../src/harness/cli/conversation.ts"],"names":[],"mappings":"AAKA,OAAO,EA2BL,KAAK,eAAe,EAsBrB,MAAM,uBAAuB,CAAC;AAwB/B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,WAAW,CAAC;AA2B/C,OAAO,EAA+B,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAClF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAsB9C;;;;;;;;;;;;;;;;;GAiBG;AAEH,KAAK,YAAY,GAAG,OAAO,CAAC,aAAa,EAAE;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC,CAAC;AAyG9D,4GAA4G;AAC5G,wBAAgB,cAAc,CAAC,IAAI,EAAE,eAAe,GAAG,eAAe,CAMrE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,eAAe,GAAG,eAAe,CAMpE;AA2ED,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,YAAY,EAAE,EAAE,EAAE,SAAS,EAAE,SAAS,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAE3H"}
@@ -32,6 +32,7 @@ import { defaultEffort, isReasoningEffort } from "../providers/index.js";
32
32
  import { openBrowser } from "../tui/open-browser.js";
33
33
  import { acknowledgedNotices, acknowledgeNotice, approvalNotice, mcpStartNotices, StartupNotices } from "./startup-notices.js";
34
34
  import { failureInfo } from "./outcome.js";
35
+ import { connectProvider, MANUAL_CONNECT_COMMANDS, signIn } from "./first-run-connect.js";
35
36
  import { createSessionRenderer } from "./renderers.js";
36
37
  import { createRuntime } from "./runtime.js";
37
38
  import { changePathspecs, commitAll, commitSelected, commitsSince, reviewDiff, uncommittedChanges } from "./session-git.js";
@@ -130,10 +131,10 @@ function missingRoute(runtime) {
130
131
  const hints = profileHintsFor(runtime.canonical, "orchestrator").map((hint) => `${hint.provider}/${hint.model}`);
131
132
  return new HarnessError({
132
133
  code: "config_invalid",
133
- message: `no model route is configured for the conversation: add a routes entry with tier session (or orchestrator) to ${path.join(runtime.home, "config.yaml")}, or pass --profile session=<provider>/<model>${hints.length === 0 ? "" : ` (the canonical model profiles suggest ${hints.join(" or ")})`}`,
134
+ message: `no model is connected for the conversation: run ${MANUAL_CONNECT_COMMANDS} (or start syn in an interactive terminal to sign in there; --profile session=<provider>/<model> sets one for a single run)${hints.length === 0 ? "" : `; the canonical model profiles suggest ${hints.join(" or ")}`}`,
134
135
  workspace_effect: "none",
135
136
  retry_safe: true,
136
- next_command: "syn doctor --runtime",
137
+ next_command: "syn login openai && syn config set routes.session openai/gpt-6-sol",
137
138
  });
138
139
  }
139
140
  /** The conversation model the user saved with `/model <tier> --save` (a preference, not a route). */
@@ -218,6 +219,8 @@ class Conversation {
218
219
  grantList = [];
219
220
  routePromise;
220
221
  routeRecorded = false;
222
+ /** The current turn's model request failed for want of a signed-in identity (a sign-in is offered after it). */
223
+ authFailure = false;
221
224
  sessionTier;
222
225
  currentModel;
223
226
  sessionId;
@@ -345,14 +348,12 @@ class Conversation {
345
348
  if (runtime.permissionMode() === "plan")
346
349
  this.modeBeforePlan = startMode === "plan" ? DEFAULT_PERMISSION_MODE : startMode;
347
350
  this.sessionTier = await savedSessionTier(runtime.home);
348
- const rule = sessionRouteRule(runtime, this.sessionTier);
349
- if (rule === undefined)
351
+ let rule = sessionRouteRule(runtime, this.sessionTier);
352
+ // First run without a route: an interactive terminal connects a provider after the view starts; headless fails with the commands.
353
+ if (rule === undefined && !this.canConnect())
350
354
  return this.fail(failureInfo(missingRoute(runtime)));
351
- this.sessionTier = rule.tier;
352
- this.currentModel = rule.route.model_id;
353
- // Resolving the route may probe the provider's capabilities: it runs while the user types.
354
- this.routePromise = runtime.router.resolve({ tier: rule.tier, role: "session" }, this.outer.signal);
355
- this.routePromise.catch(() => undefined);
355
+ if (rule !== undefined)
356
+ this.adoptRule(rule);
356
357
  const ledger = new UsageLedger(runtime.home);
357
358
  this.usageLedger = ledger;
358
359
  const unsubscribe = runtime.subscribe((event) => {
@@ -377,10 +378,23 @@ class Conversation {
377
378
  const resumed = await this.openResumed();
378
379
  const sandboxNoticeSeen = runtime.sandbox.enforcement === "full" || (await acknowledgedNotices(runtime.home)).has("sandbox-partial");
379
380
  this.notices = new StartupNotices((notice) => this.note(notice.level, notice.text), this.glyphs.sep);
380
- await this.renderer.start({ ...this.header(rule, await this.welcomeHint(resumed !== undefined)), sandboxNoticeSeen });
381
+ const hint = await this.welcomeHint(resumed !== undefined);
382
+ await this.renderer.start({ ...(rule === undefined ? this.unconnectedHeader(hint) : this.header(rule, hint)), sandboxNoticeSeen });
381
383
  // The partial-sandbox warning shows once per machine; /permissions keeps it.
382
384
  if (!sandboxNoticeSeen && this.renderer.controls?.appearance !== undefined)
383
385
  void acknowledgeNotice(runtime.home, "sandbox-partial");
386
+ if (rule === undefined) {
387
+ const connected = await connectProvider(this.connectHost());
388
+ rule = connected === undefined ? undefined : sessionRouteRule(runtime, "session");
389
+ if (rule === undefined) {
390
+ this.note("info", `No provider connected ${this.glyphs.sep} to set one up by hand: ${MANUAL_CONNECT_COMMANDS}`);
391
+ await this.renderer.stop("completed");
392
+ return EXIT_CODES.success;
393
+ }
394
+ this.adoptRule(rule);
395
+ const view = this.header(rule).welcome;
396
+ this.renderer.controls?.appearance?.updateWelcome({ model: rule.route.model_id, ...(view?.effort === undefined ? {} : { effort: view.effort }), ...(view?.plan === undefined ? {} : { plan: view.plan }) });
397
+ }
384
398
  const controls = this.renderer.controls;
385
399
  this.refreshPalette();
386
400
  this.refreshStatus();
@@ -416,7 +430,7 @@ class Conversation {
416
430
  else
417
431
  void Promise.race([this.mcpStarted, delay(1500)]).then(() => this.notices?.flush());
418
432
  // Credential pre-resolution (keychain, token refresh) happens in the background, never before the editor.
419
- void this.routePromise.then((decision) => runtime.credentials(decision.route, this.outer.signal)).catch(() => undefined);
433
+ void this.routePromise?.then((decision) => runtime.credentials(decision.route, this.outer.signal)).catch(() => undefined);
420
434
  const input = this.renderer.input;
421
435
  if (input === undefined) {
422
436
  this.note("warning", "syn agent needs input: attach a terminal or pipe messages on stdin, one per line");
@@ -455,6 +469,71 @@ class Conversation {
455
469
  this.io.stderr.write(formatHarnessError(error));
456
470
  return exitCodeFor(error.code);
457
471
  }
472
+ /** A human at a terminal who can pick and sign in to a provider (first run, or a turn that needs sign-in). */
473
+ canConnect() {
474
+ return this.io.stdinIsTTY && this.renderer.input !== undefined && this.renderer.auth.interactive;
475
+ }
476
+ adoptRule(rule) {
477
+ this.sessionTier = rule.tier;
478
+ this.currentModel = rule.route.model_id;
479
+ // Resolving the route may probe the provider's capabilities: it runs while the user types.
480
+ this.routePromise = this.runtime.router.resolve({ tier: rule.tier, role: "session" }, this.outer.signal);
481
+ this.routePromise.catch(() => undefined);
482
+ this.routeRecorded = false;
483
+ }
484
+ /** The welcome before a provider is connected (first run). */
485
+ unconnectedHeader(hint) {
486
+ const runtime = this.runtime;
487
+ const permissionMode = runtime.permissionMode();
488
+ const commit = buildCommit();
489
+ return {
490
+ welcome: { ...(commit === undefined ? {} : { commit }), path: shortenPath(runtime.workspaceRoot, userHome(this.io.env)), ...(hint === undefined ? {} : { hint }) },
491
+ workspaceRoot: runtime.workspaceRoot,
492
+ gitBranch: runtime.gitBranch,
493
+ policyMode: runtime.policyMode,
494
+ routes: [],
495
+ sandboxEnforcement: runtime.sandbox.enforcement,
496
+ notices: [...runtime.config.warnings.map((warning) => warning.message), ...runtime.canonical.diagnostics.map((diagnostic) => `canonical .ai: ${diagnostic}`)],
497
+ version: SYNORCH_VERSION,
498
+ model: "not connected",
499
+ contextWindowTokens: DEFAULT_CONTEXT_WINDOW,
500
+ ...(permissionMode === undefined ? {} : { permissionMode }),
501
+ };
502
+ }
503
+ connectHost() {
504
+ return {
505
+ runtime: this.runtime,
506
+ renderer: this.renderer,
507
+ overrides: this.overrides,
508
+ env: this.io.env,
509
+ cwd: this.runtime.workspaceRoot,
510
+ signal: this.outer.signal,
511
+ ok: this.glyphs.ok,
512
+ sep: this.glyphs.sep,
513
+ choose: (question, signal) => this.choose(question, signal),
514
+ print: (lines) => this.print(lines),
515
+ warn: (line) => this.note("warning", line),
516
+ };
517
+ }
518
+ /** A turn failed because the route's identity is not signed in: offer the sign-in here, then the message can be sent again. */
519
+ async offerSignIn(provider, method, typed) {
520
+ const label = method === "oauth-subscription" ? "ChatGPT" : method === "cli-bridge" ? "Claude Code" : `${provider === "openai" ? "OpenAI" : "Anthropic"} API key`;
521
+ const answer = await this.choose({ question: `Sign in to ${label} now?`, header: "Sign in", options: [{ label: "Sign in", recommended: true }, { label: "Not now" }], allowOther: false, escapeLabel: "not now", tone: "neutral" }, this.outer.signal).catch(() => undefined);
522
+ if (answer?.kind !== "selected" || answer.indices[0] !== 0)
523
+ return;
524
+ if (!(await signIn(this.connectHost(), provider, method)))
525
+ return;
526
+ const rule = sessionRouteRule(this.runtime, this.sessionTier);
527
+ if (rule !== undefined)
528
+ this.adoptRule(rule);
529
+ const controls = this.renderer.controls;
530
+ if (typed.trim() !== "" && controls?.setEditorText !== undefined) {
531
+ controls.setEditorText(typed);
532
+ this.print([`${this.glyphs.ok} Signed in ${this.glyphs.sep} press Enter to send your message again`]);
533
+ }
534
+ else
535
+ this.print([`${this.glyphs.ok} Signed in ${this.glyphs.sep} send your message again`]);
536
+ }
458
537
  header(rule, hint) {
459
538
  const permissionMode = this.runtime.permissionMode();
460
539
  const runtime = this.runtime;
@@ -539,6 +618,8 @@ class Conversation {
539
618
  }
540
619
  if (recorded.session_id !== this.sessionId)
541
620
  return;
621
+ if (recorded.type === "model/response_failed" && AUTH_FAILURES.has(recorded.data.error.code))
622
+ this.authFailure = true;
542
623
  if (recorded.type === "turn/started")
543
624
  this.turnId = recorded.data.turn_id;
544
625
  if (recorded.type === "model/request_prepared") {
@@ -1244,6 +1325,7 @@ class Conversation {
1244
1325
  this.active = active;
1245
1326
  this.turnRunning = true;
1246
1327
  let outcome;
1328
+ this.authFailure = false;
1247
1329
  try {
1248
1330
  outcome = await driver.runTurn({
1249
1331
  sessionId: log.sessionId,
@@ -1261,8 +1343,12 @@ class Conversation {
1261
1343
  }, active.signal);
1262
1344
  }
1263
1345
  catch (error) {
1264
- if (!active.signal.aborted)
1265
- this.showFailure(failureInfo(error));
1346
+ if (!active.signal.aborted) {
1347
+ const failure = failureInfo(error);
1348
+ if (failure.code === "auth_required" || failure.code === "auth_expired")
1349
+ this.authFailure = true;
1350
+ this.showFailure(failure);
1351
+ }
1266
1352
  }
1267
1353
  finally {
1268
1354
  this.turnRunning = false;
@@ -1270,6 +1356,11 @@ class Conversation {
1270
1356
  this.active = undefined;
1271
1357
  this.submittedAt = undefined;
1272
1358
  }
1359
+ if (this.authFailure && !active.signal.aborted && outcome?.outcome !== "completed" && this.canConnect()) {
1360
+ this.authFailure = false;
1361
+ await this.offerSignIn(route.route.provider_id, route.route.auth_method, followUp ? "" : typed);
1362
+ return false;
1363
+ }
1273
1364
  // A message typed while the last step settled could not be delivered in this turn: it starts the next one.
1274
1365
  const leftover = driver.drainSteers?.() ?? [];
1275
1366
  if (leftover.length > 0)
@@ -3684,6 +3775,8 @@ class Conversation {
3684
3775
  this.print(tracker.view().tasks.map((task) => `${task.key} (${task.role}) ${task.state.replaceAll("_", " ")}${task.dependsOn === undefined || task.dependsOn.length === 0 ? "" : ` after ${task.dependsOn.join(", ")}`}`));
3685
3776
  }
3686
3777
  }
3778
+ /** Provider failures a sign-in fixes. */
3779
+ const AUTH_FAILURES = new Set(["unauthenticated", "auth_expired"]);
3687
3780
  const PERMISSION_MODE_WORDS = ["ask", "auto", "full", "plan"];
3688
3781
  /** K8: the welcome hint offers /resume for a conversation newer than this. */
3689
3782
  const RESUME_HINT_MS = 12 * 60 * 60 * 1000;