typeclaw 0.36.0 → 0.36.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.36.0",
3
+ "version": "0.36.2",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -48,7 +48,7 @@
48
48
  "@mariozechner/pi-tui": "^0.67.3",
49
49
  "@modelcontextprotocol/sdk": "^1.29.0",
50
50
  "@mozilla/readability": "^0.6.0",
51
- "agent-messenger": "2.19.1",
51
+ "agent-messenger": "2.19.2",
52
52
  "cheerio": "^1.2.0",
53
53
  "citty": "^0.2.2",
54
54
  "cron-parser": "^5.5.0",
@@ -383,6 +383,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
383
383
  originatingSessionId: sessionManager.getSessionId(),
384
384
  ...(options.stream ? { stream: options.stream } : {}),
385
385
  ...buildRestartHandoffWiring(options, sessionManager),
386
+ triggeringAuthorIdProvider: () => currentChannelAuthor(getOrigin),
386
387
  }),
387
388
  ]
388
389
  : []),
@@ -523,6 +524,16 @@ export function buildRestartHandoffWiring(
523
524
  return { agentDir, originatingSessionFile: sessionFile, handoffOrigin }
524
525
  }
525
526
 
527
+ // Reads the LIVE turn author at restart time, not the session-creation
528
+ // snapshot. A channel session is long-lived and multi-principal: the
529
+ // self-restart tool fires on whatever turn triggered it, so the handoff must
530
+ // carry that turn's author (originRef.current), not whoever first opened the
531
+ // session. Returns undefined for non-channel/no-author origins.
532
+ export function currentChannelAuthor(getOrigin: () => SessionOrigin | undefined): string | undefined {
533
+ const origin = getOrigin()
534
+ return origin?.kind === 'channel' ? origin.lastInboundAuthorId : undefined
535
+ }
536
+
526
537
  function restartHandoffOriginFor(origin: SessionOrigin): RestartHandoffOrigin | null {
527
538
  if (origin.kind === 'tui') return { kind: 'tui' }
528
539
  if (origin.kind === 'channel') {
@@ -38,6 +38,7 @@ import type {
38
38
  import {
39
39
  buildSandboxedCommand,
40
40
  canMountRealProc,
41
+ commandNeedsRealProc,
41
42
  DEFAULT_SANDBOX_ENV,
42
43
  ensureBwrapAvailable,
43
44
  ensureSessionTmpDir,
@@ -51,6 +52,7 @@ import {
51
52
  resolveProtectedZones,
52
53
  resolveSandboxSymlinks,
53
54
  resolveWritableZones,
55
+ SandboxDegradedProcError,
54
56
  type SandboxProcStrategy,
55
57
  subtractMasked,
56
58
  } from '@/sandbox'
@@ -643,6 +645,15 @@ async function applyBashSandbox(
643
645
  // it would never reach the sandboxed process (the non-sandboxed spawnHook
644
646
  // path does not run when the command is rewritten to a bwrap invocation).
645
647
  const proc = await resolveProcStrategy()
648
+ // Fail fast with an actionable error when /proc degraded to tmpfs AND the
649
+ // command needs a real /proc: under tmpfs Bun would otherwise abort deep in its
650
+ // pipeline with the opaque "NotDir", which the model retries forever. The
651
+ // SandboxDegradedProcError message tells it this is an environment limit, not
652
+ // the command's fault. Guarded on the command so non-bun bash still runs in the
653
+ // degraded mode (it does not touch /proc/self/{fd,maps}).
654
+ if (proc === 'tmpfs' && commandNeedsRealProc(command)) {
655
+ throw new SandboxDegradedProcError()
656
+ }
646
657
  const { commandString } = buildSandboxedCommand(command, {
647
658
  mounts: [
648
659
  { type: 'ro-bind', source: agentDir, dest: agentDir },
@@ -35,6 +35,10 @@ export type RequestContainerRestartOptions = {
35
35
  // startup). Required alongside agentDir + originatingSessionFile for the
36
36
  // handoff to be written; omitting it skips the handoff entirely.
37
37
  handoffOrigin?: RestartHandoffOrigin
38
+ // Author of the inbound that owned the restarting session, carried so the
39
+ // reopened channel session re-seeds the requester's identity on its resume
40
+ // turn (see RestartHandoff.triggeringAuthorId).
41
+ triggeringAuthorId?: string
38
42
  restartedAt?: string
39
43
  }
40
44
 
@@ -54,6 +58,7 @@ export async function requestContainerRestart({
54
58
  originatingSessionId,
55
59
  originatingSessionFile,
56
60
  handoffOrigin,
61
+ triggeringAuthorId,
57
62
  restartedAt,
58
63
  }: RequestContainerRestartOptions): Promise<RequestContainerRestartResult> {
59
64
  const request = { kind: 'restart' as const, containerName, build: build === true }
@@ -103,6 +108,7 @@ export async function requestContainerRestart({
103
108
  originatingSessionId,
104
109
  originatingSessionFile: basename(originatingSessionFile),
105
110
  origin: handoffOrigin,
111
+ ...(triggeringAuthorId !== undefined ? { triggeringAuthorId } : {}),
106
112
  })
107
113
  } catch {
108
114
  // intentional swallow — see the post-ACK rationale above
@@ -35,6 +35,13 @@ export type RestartHandoff = {
35
35
  originatingSessionId: string
36
36
  originatingSessionFile: string
37
37
  origin: RestartHandoffOrigin
38
+ // Author of the inbound that owned the originating session at restart time.
39
+ // The synthetic resume turn has no inbound of its own, so without this a
40
+ // multi-principal channel session re-seeds its turn author from nothing and
41
+ // an author-scoped role (`discord:* author:U_OWNER`) silently demotes to
42
+ // whatever bare-channel rule matches on every "I'm back" turn. Optional and
43
+ // additive: pre-field v2 handoffs and tui handoffs omit it.
44
+ triggeringAuthorId?: string
38
45
  }
39
46
 
40
47
  // Atomic write via `.tmp` + rename so a crash mid-write never leaves the
@@ -158,6 +165,9 @@ function parseHandoff(raw: string): RestartHandoff | null {
158
165
  originatingSessionId: obj.originatingSessionId,
159
166
  originatingSessionFile: obj.originatingSessionFile,
160
167
  origin,
168
+ ...(typeof obj.triggeringAuthorId === 'string' && obj.triggeringAuthorId !== ''
169
+ ? { triggeringAuthorId: obj.triggeringAuthorId }
170
+ : {}),
161
171
  }
162
172
  }
163
173
 
@@ -57,6 +57,12 @@ export type CreateRestartToolOptions = {
57
57
  // alongside `originatingSessionFile` for the handoff to be written; omit to
58
58
  // skip the handoff. See buildRestartHandoffWiring in src/agent/index.ts.
59
59
  handoffOrigin?: RestartHandoffOrigin
60
+ // Resolves the LIVE turn author at execute time (not session-creation), so a
61
+ // channel self-restart in a long-lived multi-principal session resumes under
62
+ // the author of the turn that triggered the restart — not whoever opened the
63
+ // session. Called inside execute(); returns undefined for tui/no-author
64
+ // origins. See RestartHandoff.triggeringAuthorId.
65
+ triggeringAuthorIdProvider?: () => string | undefined
60
66
  }
61
67
 
62
68
  export type RestartToolDetails = { ok: boolean; containerName: string; reason?: string }
@@ -75,6 +81,7 @@ export function createRestartTool({
75
81
  agentDir,
76
82
  originatingSessionFile,
77
83
  handoffOrigin,
84
+ triggeringAuthorIdProvider,
78
85
  }: CreateRestartToolOptions) {
79
86
  const doExit = exit ?? ((code: number) => process.exit(code))
80
87
 
@@ -103,6 +110,7 @@ export function createRestartTool({
103
110
  }),
104
111
  async execute(_toolCallId, params) {
105
112
  const build = params.build === true
113
+ const triggeringAuthorId = triggeringAuthorIdProvider?.()
106
114
  // requestContainerRestart owns the post-ACK broadcast->handoff ordering:
107
115
  // on a successful ACK it publishes the container-restarting notice (which
108
116
  // every live session's subscribeRestartNotice turns into a transcript
@@ -121,6 +129,7 @@ export function createRestartTool({
121
129
  ...(agentDir !== undefined ? { agentDir } : {}),
122
130
  ...(originatingSessionFile !== undefined ? { originatingSessionFile } : {}),
123
131
  ...(handoffOrigin !== undefined ? { handoffOrigin } : {}),
132
+ ...(triggeringAuthorId !== undefined ? { triggeringAuthorId } : {}),
124
133
  })
125
134
  if (!result.ok) {
126
135
  const details: RestartToolDetails = { ok: false, containerName, reason: result.reason }
@@ -45,7 +45,15 @@ Commit message comes from the `backup-message` subagent, which sees a truncated
45
45
 
46
46
  ## What it pushes
47
47
 
48
- When `pushToOrigin: true` and the current branch has an upstream (`git rev-parse --abbrev-ref --symbolic-full-name @{upstream}` succeeds), the runner runs `git push`. On non-fast-forward rejection, it runs `git fetch` then `git rebase <upstream>` then `git push` again.
48
+ When `pushToOrigin: true`, the runner picks a push plan after committing:
49
+
50
+ - **Branch has an upstream** (`git rev-parse --abbrev-ref --symbolic-full-name @{upstream}` succeeds): run `git push`.
51
+ - **No upstream, but `origin` exists and `HEAD` is a real branch**: run `git push -u origin HEAD:<branch>`, which pushes _and_ establishes tracking in one shot. This is the common case for a fresh agent folder nobody ran `git push -u` on by hand — previously the runner committed forever and never pushed here. Every later cycle then takes the plain-upstream path.
52
+ - **No `origin`, or detached `HEAD`**: commit only (a legitimate offline / no-remote state). No push is attempted and no diagnostics are written.
53
+
54
+ On non-fast-forward rejection (either push shape), the runner runs `git fetch` then `git rebase <remote-branch>` then re-pushes with the same args. Only `origin` is ever acted on for the set-upstream case — the runner never guesses a destination the operator didn't configure.
55
+
56
+ **Credentials.** The runner spawns `git` directly (not via the `bash` tool), so the `github-cli-auth` plugin's `tool.before` credential injection does **not** fire for it. For **GitHub App auth** the backup plugin mints a per-repo installation token for `origin`'s github.com slug and injects it into the runner's git env via the same `GIT_ASKPASS` helper the bash path uses (token in `TYPECLAW_GIT_TOKEN`, never in argv/config; ssh remotes rewritten to https via `insteadOf`). Classic/fine-grained PATs, SSH-key, and credential-helper setups are left untouched — the runner uses its inherited process env. Non-github origins are never minted for.
49
57
 
50
58
  If any network step fails (rebase conflict, auth failure, network timeout), the runner aborts cleanly and spawns the `backup-diagnose` subagent. That subagent has `bash`, `read`, and `write` tools and writes a short human-readable report to `<agentDir>/sessions/backup-diagnostics.log`. The diagnose subagent is explicitly forbidden from force-pushing or resolving merge conflicts itself.
51
59
 
@@ -77,5 +85,6 @@ This feature came up as: "periodically check for dirty files and commit; LLM pic
77
85
 
78
86
  ## Tests
79
87
 
80
- - `runner.test.ts` — deterministic runner unit tests (status parsing, force-add of `sessions/`, push-only-with-upstream, rebase-on-non-fast-forward, diagnose-on-rebase-conflict, advisory-throw isolation, sanitize-commit-message).
88
+ - `runner.test.ts` — deterministic runner unit tests (status parsing, force-add of `sessions/`, push-with-upstream, push-and-set-upstream when origin exists but tracking is absent, commit-only on no-origin / detached-HEAD, rebase-on-non-fast-forward for both push shapes, diagnose-on-rebase-conflict, advisory-throw isolation, sanitize-commit-message).
89
+ - `git-auth.test.ts` — credential-env resolution (App-auth mints for the origin slug; PAT/SSH/non-github/unavailable-token all fall back to inherited env).
81
90
  - `index.test.ts` — plugin composition tests (subagent/hook surface, config schema defaults and validation, debounce, active-turn gating, self-induced-turn exclusion, coalescing).
@@ -0,0 +1,58 @@
1
+ import type { GithubTokenResolveResult } from '@/channels/github-token-bridge'
2
+
3
+ import { ensureGitAskPassHelper } from '../github-cli-auth/git-askpass'
4
+ import { parseGithubRepoFromGitUrl } from '../github-cli-auth/git-command'
5
+ import { shouldMintAppToken } from '../github-cli-auth/token-class'
6
+
7
+ export type BackupGitAuthEnv = Record<string, string>
8
+
9
+ export type BackupPushAuthDeps = {
10
+ hasAppTokenResolver: () => boolean
11
+ ghToken: string | undefined
12
+ resolveTokenForRepo: (repoSlug: string) => Promise<GithubTokenResolveResult>
13
+ resolveOriginPushUrl: (cwd: string) => Promise<string | null>
14
+ ensureAskPassHelper: () => Promise<string>
15
+ }
16
+
17
+ // The backup runner spawns git directly (not via the bash tool), so the
18
+ // `github-cli-auth` plugin's `tool.before` credential injection never fires for
19
+ // its push. Without this, App-auth agents push with no credentials and fail.
20
+ // We mirror that plugin exactly: only mint for App auth, only for a github.com
21
+ // origin, scoped to the origin's own repo slug. PAT/SSH/credential-helper setups
22
+ // return null and keep using the runner's inherited process env.
23
+ export async function resolveBackupPushAuthEnv(
24
+ cwd: string,
25
+ deps: BackupPushAuthDeps,
26
+ ): Promise<BackupGitAuthEnv | null> {
27
+ if (!shouldMintAppToken(deps.ghToken, deps.hasAppTokenResolver())) return null
28
+
29
+ const originUrl = await deps.resolveOriginPushUrl(cwd)
30
+ if (originUrl === null) return null
31
+
32
+ const slug = parseGithubRepoFromGitUrl(originUrl)
33
+ if (slug === null) return null
34
+
35
+ const token = await deps.resolveTokenForRepo(slug)
36
+ if (token.kind !== 'token') return null
37
+
38
+ const askpass = await deps.ensureAskPassHelper()
39
+
40
+ // Token rides in TYPECLAW_GIT_TOKEN (read by the askpass helper), never in
41
+ // argv/config. The insteadOf rewrites map ssh/scp github remotes to https so
42
+ // the askpass credential applies; GIT_TERMINAL_PROMPT=0 fails fast instead of
43
+ // hanging on a prompt. Mirrors github-cli-auth/index.ts.
44
+ return {
45
+ GIT_ASKPASS: askpass,
46
+ TYPECLAW_GIT_TOKEN: token.token,
47
+ GIT_TERMINAL_PROMPT: '0',
48
+ GIT_CONFIG_COUNT: '2',
49
+ GIT_CONFIG_KEY_0: 'url.https://github.com/.insteadOf',
50
+ GIT_CONFIG_VALUE_0: 'git@github.com:',
51
+ GIT_CONFIG_KEY_1: 'url.https://github.com/.insteadOf',
52
+ GIT_CONFIG_VALUE_1: 'ssh://git@github.com/',
53
+ }
54
+ }
55
+
56
+ export function makeDefaultAskPassEnsurer(): () => Promise<string> {
57
+ return () => ensureGitAskPassHelper()
58
+ }
@@ -3,6 +3,7 @@ import { z } from 'zod'
3
3
  import { withGitLock } from '@/git/mutex'
4
4
  import { definePlugin, type PluginContext, type SpawnSubagentOptions, type Subagent } from '@/plugin'
5
5
 
6
+ import { type BackupPushAuthDeps, makeDefaultAskPassEnsurer, resolveBackupPushAuthEnv } from './git-auth'
6
7
  import { COMMIT_TIMEOUT_MS, makeDefaultGitSpawn, NETWORK_TIMEOUT_MS, runBackup, type BackupResult } from './runner'
7
8
  import {
8
9
  cleanupMessageFile,
@@ -163,6 +164,7 @@ async function runBackupOnce(
163
164
  agentDir: string
164
165
  logger: { info: (m: string) => void; warn: (m: string) => void }
165
166
  spawnSubagent: PluginContext['spawnSubagent']
167
+ github: PluginContext['github']
166
168
  },
167
169
  ): Promise<BackupResult> {
168
170
  const messagePath = messageFilePath(payload.agentDir)
@@ -175,11 +177,21 @@ async function runBackupOnce(
175
177
  spawnedByOrigin: { kind: 'tui', sessionId: 'backup-runner' },
176
178
  }
177
179
 
180
+ // App-auth agents need a minted per-repo token for the runner's push (it
181
+ // bypasses the bash tool's credential hook). Only computed when we'll push;
182
+ // PAT/SSH/non-github fall back to null. Passed as `pushEnv` so the runner
183
+ // applies it to push/fetch ONLY — never to local commands like `git commit`,
184
+ // which can run repo-controlled hooks that would otherwise see the token.
185
+ const pushEnv = payload.pushToOrigin
186
+ ? ((await resolveBackupAuthEnv(payload.agentDir, ctx.github, ctx.logger)) ?? undefined)
187
+ : undefined
188
+
178
189
  const result = await withGitLock(payload.agentDir, () =>
179
190
  runBackup(
180
191
  { cwd: payload.agentDir, pushToOrigin: payload.pushToOrigin },
181
192
  {
182
193
  gitSpawn: makeDefaultGitSpawn(),
194
+ pushEnv,
183
195
  pickCommitMessage: async ({ status, diffstat }) => {
184
196
  await cleanupMessageFile(messagePath)
185
197
  const messagePayload: CommitMessagePayload = {
@@ -221,6 +233,48 @@ async function runBackupOnce(
221
233
  return result
222
234
  }
223
235
 
236
+ async function resolveBackupAuthEnv(
237
+ agentDir: string,
238
+ github: PluginContext['github'],
239
+ logger: { warn: (m: string) => void },
240
+ ): Promise<Record<string, string> | null> {
241
+ const deps: BackupPushAuthDeps = {
242
+ hasAppTokenResolver: github.hasAppTokenResolver,
243
+ ghToken: process.env.GH_TOKEN,
244
+ resolveTokenForRepo: github.resolveTokenForRepo,
245
+ resolveOriginPushUrl,
246
+ ensureAskPassHelper: makeDefaultAskPassEnsurer(),
247
+ }
248
+ try {
249
+ return await resolveBackupPushAuthEnv(agentDir, deps)
250
+ } catch {
251
+ // Credential prep is best-effort: a resolver failure must not abort the
252
+ // backup. Falls back to the runner's inherited env (commit still happens),
253
+ // and the push's own failure path diagnoses if it then can't authenticate.
254
+ // No slug/token/error detail is logged — those are credential-adjacent.
255
+ logger.warn('GitHub backup auth unavailable; continuing with inherited git credentials')
256
+ return null
257
+ }
258
+ }
259
+
260
+ async function resolveOriginPushUrl(cwd: string): Promise<string | null> {
261
+ const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
262
+ if (!bun) return null
263
+ try {
264
+ const proc = bun.spawn({
265
+ cmd: ['git', '-C', cwd, 'remote', 'get-url', '--push', 'origin'],
266
+ stdout: 'pipe',
267
+ stderr: 'ignore',
268
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' },
269
+ })
270
+ if ((await proc.exited) !== 0) return null
271
+ const out = (await new Response(proc.stdout).text()).trim()
272
+ return out === '' ? null : out
273
+ } catch {
274
+ return null
275
+ }
276
+ }
277
+
224
278
  function describeResult(r: BackupResult): string {
225
279
  if (r.ok) return r.kind
226
280
  return `failed (${r.kind}): ${r.reason}`
@@ -21,12 +21,20 @@ export type GitSpawnResult = {
21
21
  timedOut: boolean
22
22
  }
23
23
 
24
- export type GitSpawn = (args: readonly string[], opts: { cwd: string; timeoutMs: number }) => Promise<GitSpawnResult>
24
+ export type GitSpawn = (
25
+ args: readonly string[],
26
+ opts: { cwd: string; timeoutMs: number; env?: Record<string, string> },
27
+ ) => Promise<GitSpawnResult>
25
28
 
26
29
  export type BackupRunnerDeps = {
27
30
  gitSpawn: GitSpawn
28
31
  pickCommitMessage: (input: { status: string; diffstat: string }) => Promise<string>
29
32
  diagnoseFailure?: (input: BackupFailureInput) => Promise<void>
33
+ // Credential env (GIT_ASKPASS/TYPECLAW_GIT_TOKEN/insteadOf) applied ONLY to
34
+ // network git invocations (push/fetch). It is deliberately NOT given to local
35
+ // commands — `git commit` can run repo-controlled hooks, which must never see
36
+ // the minted token.
37
+ pushEnv?: Record<string, string>
30
38
  now?: () => number
31
39
  }
32
40
 
@@ -44,9 +52,15 @@ export type BackupFailureInput = {
44
52
  }
45
53
 
46
54
  export type BackupResult =
47
- | { ok: true; kind: 'no-repo' | 'clean' | 'committed' | 'pushed' | 'rebased-and-pushed' }
55
+ | { ok: true; kind: 'no-repo' | 'clean' | 'committed' | 'pushed' | 'pushed-set-upstream' | 'rebased-and-pushed' }
48
56
  | { ok: false; kind: 'commit-failed' | 'push-failed' | 'rebase-failed' | 'aborted'; reason: string }
49
57
 
58
+ type ActivePushPlan =
59
+ | { kind: 'upstream'; upstreamRef: string }
60
+ | { kind: 'set-upstream'; remote: string; branch: string }
61
+
62
+ type PushPlan = ActivePushPlan | { kind: 'skip' }
63
+
50
64
  export async function runBackup(options: BackupRunnerOptions, deps: BackupRunnerDeps): Promise<BackupResult> {
51
65
  const { cwd, pushToOrigin } = options
52
66
 
@@ -113,24 +127,70 @@ export async function runBackup(options: BackupRunnerOptions, deps: BackupRunner
113
127
 
114
128
  if (!pushToOrigin) return { ok: true, kind: 'committed' }
115
129
 
130
+ const plan = await resolvePushPlan(cwd, deps)
131
+ if (plan.kind === 'skip') return { ok: true, kind: 'committed' }
132
+
133
+ return pushWithRecovery(cwd, deps, plan)
134
+ }
135
+
136
+ // `@{upstream}` resolution failing was previously treated as "no push" — but a
137
+ // fresh agent repo that nobody ran `git push -u` on has a configured `origin`
138
+ // and no tracking ref, so the runner committed forever and never pushed. The
139
+ // correct gate when `pushToOrigin` is on is "origin exists and HEAD is a real
140
+ // branch": then we push AND set the upstream in one shot, and every later run
141
+ // takes the plain-upstream path. No remote / detached HEAD stays commit-only
142
+ // (a legitimate offline state), so it returns `skip` rather than diagnosing.
143
+ async function resolvePushPlan(cwd: string, deps: BackupRunnerDeps): Promise<PushPlan> {
116
144
  const upstream = await deps.gitSpawn(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], {
117
145
  cwd,
118
146
  timeoutMs: COMMIT_TIMEOUT_MS,
119
147
  })
120
- if (upstream.exitCode !== 0) return { ok: true, kind: 'committed' }
148
+ if (upstream.exitCode === 0 && upstream.stdout.trim().length > 0) {
149
+ return { kind: 'upstream', upstreamRef: upstream.stdout.trim() }
150
+ }
121
151
 
122
- const upstreamRef = upstream.stdout.trim()
123
- if (upstreamRef.length === 0) return { ok: true, kind: 'committed' }
152
+ // Only `origin` is acted on: picking "the first remote" when origin is absent
153
+ // would guess a destination the operator never configured. `get-url` (not
154
+ // `get-url --push`) is enough here — we only need to know origin EXISTS and
155
+ // is named `origin`; the push targets `origin` by name regardless of pushurl.
156
+ const origin = await deps.gitSpawn(['remote', 'get-url', 'origin'], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
157
+ if (origin.exitCode !== 0 || origin.stdout.trim().length === 0) return { kind: 'skip' }
124
158
 
125
- const push = await deps.gitSpawn(['push'], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
126
- if (push.exitCode === 0) return { ok: true, kind: 'pushed' }
159
+ // `symbolic-ref --short HEAD` fails on a detached HEAD (no branch to set an
160
+ // upstream for); `rev-parse --abbrev-ref HEAD` would have returned the literal
161
+ // "HEAD" and we'd have tried to push a branch named HEAD. Skip cleanly.
162
+ const branch = await deps.gitSpawn(['symbolic-ref', '--short', 'HEAD'], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
163
+ if (branch.exitCode !== 0 || branch.stdout.trim().length === 0) return { kind: 'skip' }
164
+
165
+ return { kind: 'set-upstream', remote: 'origin', branch: branch.stdout.trim() }
166
+ }
167
+
168
+ // Both entry points (plain push and first-time set-upstream push) share the
169
+ // non-fast-forward recovery: fetch, rebase onto the intended remote branch,
170
+ // re-push. Keeping one helper stops the set-upstream path from silently becoming
171
+ // a weaker duplicate that skips recovery.
172
+ async function pushWithRecovery(cwd: string, deps: BackupRunnerDeps, plan: ActivePushPlan): Promise<BackupResult> {
173
+ const pushArgs = pushArgsFor(plan)
174
+ const rebaseRef = plan.kind === 'upstream' ? plan.upstreamRef : `${plan.remote}/${plan.branch}`
175
+ // In the set-upstream case there is no tracking ref yet, so a bare `git fetch`
176
+ // has no configured remote to default to — fetch the same remote we rebase
177
+ // onto. The upstream case keeps bare `fetch` (its tracking config resolves it).
178
+ const fetchArgs = plan.kind === 'upstream' ? ['fetch'] : ['fetch', plan.remote]
179
+ const pushedKind: BackupResult = { ok: true, kind: plan.kind === 'upstream' ? 'pushed' : 'pushed-set-upstream' }
180
+ // Credentials ride ONLY on the network calls (push/fetch). The rebase is
181
+ // local (it replays onto an already-fetched remote-tracking ref), so it runs
182
+ // token-free like every other local command.
183
+ const net = { cwd, timeoutMs: NETWORK_TIMEOUT_MS, env: deps.pushEnv }
184
+
185
+ const push = await deps.gitSpawn(pushArgs, net)
186
+ if (push.exitCode === 0) return pushedKind
127
187
 
128
188
  if (!isNonFastForward(push)) {
129
189
  await maybeDiagnose(deps, { cwd, stage: 'push', exitCode: push.exitCode, stderr: push.stderr, stdout: push.stdout })
130
190
  return { ok: false, kind: 'push-failed', reason: shortErr(push) }
131
191
  }
132
192
 
133
- const fetch = await deps.gitSpawn(['fetch'], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
193
+ const fetch = await deps.gitSpawn(fetchArgs, net)
134
194
  if (fetch.exitCode !== 0) {
135
195
  await maybeDiagnose(deps, {
136
196
  cwd,
@@ -142,7 +202,7 @@ export async function runBackup(options: BackupRunnerOptions, deps: BackupRunner
142
202
  return { ok: false, kind: 'push-failed', reason: `git fetch failed: ${shortErr(fetch)}` }
143
203
  }
144
204
 
145
- const rebase = await deps.gitSpawn(['rebase', upstreamRef], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
205
+ const rebase = await deps.gitSpawn(['rebase', rebaseRef], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
146
206
  if (rebase.exitCode !== 0) {
147
207
  await deps.gitSpawn(['rebase', '--abort'], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
148
208
  await maybeDiagnose(deps, {
@@ -155,7 +215,7 @@ export async function runBackup(options: BackupRunnerOptions, deps: BackupRunner
155
215
  return { ok: false, kind: 'rebase-failed', reason: `git rebase failed: ${shortErr(rebase)}` }
156
216
  }
157
217
 
158
- const push2 = await deps.gitSpawn(['push'], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
218
+ const push2 = await deps.gitSpawn(pushArgs, net)
159
219
  if (push2.exitCode !== 0) {
160
220
  await maybeDiagnose(deps, {
161
221
  cwd,
@@ -169,6 +229,13 @@ export async function runBackup(options: BackupRunnerOptions, deps: BackupRunner
169
229
  return { ok: true, kind: 'rebased-and-pushed' }
170
230
  }
171
231
 
232
+ function pushArgsFor(plan: ActivePushPlan): string[] {
233
+ if (plan.kind === 'upstream') return ['push']
234
+ // `HEAD:<branch>` is explicit about pushing the current commit to the named
235
+ // remote branch, avoiding any reliance on local refspec defaults.
236
+ return ['push', '-u', plan.remote, `HEAD:${plan.branch}`]
237
+ }
238
+
172
239
  async function maybeDiagnose(deps: BackupRunnerDeps, input: BackupFailureInput): Promise<void> {
173
240
  if (!deps.diagnoseFailure) return
174
241
  try {
@@ -217,7 +284,7 @@ function sanitizeCommitMessage(raw: string): string {
217
284
  }
218
285
 
219
286
  export function makeDefaultGitSpawn(): GitSpawn {
220
- return withIndexLockRetry(async (args, { cwd, timeoutMs }) => {
287
+ return withIndexLockRetry(async (args, { cwd, timeoutMs, env }) => {
221
288
  const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
222
289
  if (!bun) {
223
290
  return { exitCode: 127, stdout: '', stderr: 'Bun runtime not available', timedOut: false }
@@ -225,12 +292,15 @@ export function makeDefaultGitSpawn(): GitSpawn {
225
292
  const controller = new AbortController()
226
293
  const timer = setTimeout(() => controller.abort(), timeoutMs)
227
294
  try {
295
+ // Per-call `env` (credentials for push/fetch) is applied LAST so its
296
+ // GIT_TERMINAL_PROMPT wins; NONINTERACTIVE_ENV's pager/GCM settings still
297
+ // apply to every call. Local commands pass no `env` and stay token-free.
228
298
  const proc = bun.spawn({
229
299
  cmd: ['git', ...args],
230
300
  cwd,
231
301
  stdout: 'pipe',
232
302
  stderr: 'pipe',
233
- env: { ...process.env, ...NONINTERACTIVE_ENV },
303
+ env: { ...process.env, ...NONINTERACTIVE_ENV, ...env },
234
304
  signal: controller.signal,
235
305
  })
236
306
  const exitCode = await proc.exited
@@ -75,6 +75,7 @@ const EMOJI_UNICODE: Record<string, string> = {
75
75
  fire: '🔥',
76
76
  eye: '👁️',
77
77
  raised_hands: '🙌',
78
+ zipper_mouth_face: '🤐',
78
79
  }
79
80
 
80
81
  function resolveEmoji(emoji: string): string | null {
@@ -297,10 +297,22 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
297
297
 
298
298
  async start(): Promise<void> {
299
299
  const cfg = options.channelsConfigRef()
300
- for (const name of ADAPTER_IDS) {
300
+ // Safe to fan out: `live` and every router registry are keyed by adapter
301
+ // name, so concurrent starts never collide. Serial start would otherwise pay
302
+ // the sum of each adapter's connect latency instead of just the slowest.
303
+ const starts = ADAPTER_IDS.flatMap((name) => {
301
304
  const adapterCfg = cfg[name]
302
- if (adapterCfg !== undefined) await runSerially(name, () => startAdapter(name, adapterCfg))
303
- }
305
+ return adapterCfg === undefined ? [] : [runSerially(name, () => startAdapter(name, adapterCfg))]
306
+ })
307
+ // Await every launched start to settle BEFORE surfacing a failure.
308
+ // `startAdapter` converts expected per-adapter failures to `false`, so a
309
+ // rejection is an unexpected throw (e.g. `buildAdapter`) that must still
310
+ // fail-fast. But bailing on the first rejection (plain `Promise.all`) would
311
+ // leave sibling starts in flight, letting a late `live.set` orphan an adapter
312
+ // that the caller's subsequent `stop()` never sees. Settle all, then rethrow.
313
+ const results = await Promise.allSettled(starts)
314
+ const failure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
315
+ if (failure !== undefined) throw failure.reason
304
316
  },
305
317
 
306
318
  async stop(): Promise<void> {