@gotcos/glasses-server 6.82.0 → 6.84.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/CHANGELOG.md +224 -0
- package/docs/work-orchestrator.md +184 -0
- package/managed-runtime-contract.json +2 -1
- package/package.json +8 -1
- package/server/index.ts +12 -0
- package/server/lib/claude-bridge.ts +4 -0
- package/server/lib/dictation-clean.ts +26 -3
- package/server/lib/dictation-format.ts +119 -0
- package/server/lib/dictation-polish-need.ts +4 -2
- package/server/lib/glossary-learn.ts +88 -10
- package/server/lib/hallucination-filter.ts +5 -0
- package/server/lib/meeting-batch-transcribe.ts +12 -4
- package/server/lib/profile.ts +3 -1
- package/server/lib/query-job-coordinator.ts +5 -1
- package/server/lib/unsaved-audio-quarantine.ts +36 -5
- package/server/lib/whisper-local.ts +31 -5
- package/server/lib/work-chat.ts +76 -9
- package/server/lib/work-lens-rounds.ts +445 -0
- package/server/lib/work-orchestrator/discovery.ts +99 -0
- package/server/lib/work-orchestrator/input.ts +109 -0
- package/server/lib/work-orchestrator/pickup.ts +85 -0
- package/server/lib/work-orchestrator/plan-log.ts +38 -0
- package/server/lib/work-orchestrator/redact.ts +41 -0
- package/server/lib/work-orchestrator/runtime.ts +74 -0
- package/server/lib/work-orchestrator/session-log.ts +114 -0
- package/server/lib/work-orchestrator/snapshot.ts +87 -0
- package/server/lib/work-orchestrator/tag-index.ts +272 -0
- package/server/lib/work-orchestrator/tags.ts +52 -0
- package/server/lib/work-orchestrator/usage-events.ts +30 -0
- package/server/lib/work-orchestrator/usage.ts +341 -0
- package/server/routes/glossary.ts +14 -2
- package/server/routes/meeting.ts +76 -1
- package/server/routes/prompt-drafts.ts +15 -2
- package/server/routes/transcribe-stream.ts +127 -10
- package/server/routes/work-chat.ts +54 -11
- package/server/routes/work-lens-rounds.ts +179 -0
- package/server/routes/work-orchestrator.ts +71 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0 (M1, canary C3): which sessions belong to a Work card, from COS Control's snapshot and the tag index.
|
|
3
|
+
* Pure: no I/O. The rules and their ranks are in docs/work-orchestrator.md.
|
|
4
|
+
*/
|
|
5
|
+
import type { ControlSnapshot } from './snapshot.js'
|
|
6
|
+
import type { TagHit } from './tag-index.js'
|
|
7
|
+
import { tagForWorkID, tagsIn } from './tags.js'
|
|
8
|
+
|
|
9
|
+
/** Evidence kinds, strongest first. The first five make a session the card's; status and listed confirm one. */
|
|
10
|
+
export const EVIDENCE_RANK = ['link', 'created', 'forked', 'continued', 'prompt', 'status', 'listed', 'meeting'] as const
|
|
11
|
+
export type Evidence = typeof EVIDENCE_RANK[number]
|
|
12
|
+
const MEMBER: ReadonlySet<Evidence> = new Set(['link', 'created', 'forked', 'continued', 'prompt'])
|
|
13
|
+
const CONFIRMS: ReadonlySet<Evidence> = new Set(['status', 'listed'])
|
|
14
|
+
|
|
15
|
+
export interface CardSession {
|
|
16
|
+
id: string
|
|
17
|
+
/** Evidence kinds for this card, in rank order. */
|
|
18
|
+
discoveredBy: Evidence[]
|
|
19
|
+
/** True: one of ranks 1 to 5 holds. False: a suggestion (status or listed alone, or the source-meeting join). */
|
|
20
|
+
member: boolean
|
|
21
|
+
linked: boolean
|
|
22
|
+
/** Newest evidence for this card (ms). */
|
|
23
|
+
lastTouchedCard: number | null
|
|
24
|
+
/** Set when the session's newest strong evidence is on another card: it works there now, and keeps this one as history. */
|
|
25
|
+
onAnotherCard: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Ev { kind: Evidence; at: number | null }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Strong evidence per card tag and session, from Control's records and the logs.
|
|
32
|
+
* A placeholder or never-used tag (canary precision fix 7: `aaaaaaaaaaaa`, `0123456789ab`) is inert by construction: only
|
|
33
|
+
* the requested card's own tag is read for this card, and only member evidence (a Control record or a real handoff
|
|
34
|
+
* prompt) can make another card the newer owner, so a status line with an unknown tag never changes a row.
|
|
35
|
+
*/
|
|
36
|
+
function strongByTag(snapshot: ControlSnapshot | null, hits: TagHit[]): Map<string, Map<string, Ev[]>> {
|
|
37
|
+
const out = new Map<string, Map<string, Ev[]>>()
|
|
38
|
+
const add = (tag: string, sid: string | null, kind: Evidence, at: number | null) => {
|
|
39
|
+
if (!sid) return
|
|
40
|
+
let card = out.get(tag); if (!card) out.set(tag, card = new Map())
|
|
41
|
+
let list = card.get(sid); if (!list) card.set(sid, list = [])
|
|
42
|
+
list.push({ kind, at })
|
|
43
|
+
}
|
|
44
|
+
for (const l of snapshot?.links ?? []) add(tagForWorkID(l.workID), l.sessionID, 'link', l.at * 1000)
|
|
45
|
+
for (const r of snapshot?.receipts ?? []) {
|
|
46
|
+
const tag = tagForWorkID(r.workID), at = r.createdAt * 1000
|
|
47
|
+
if (r.mode === 'newSession') add(tag, r.sessionID, 'created', at)
|
|
48
|
+
else if (r.mode === 'fork') {
|
|
49
|
+
// A fork's child: journaled by Control 0.5.333, or a sessionID that is not the source (canary precision fix 1).
|
|
50
|
+
const child = r.forkChildID ?? (r.sessionID && r.sessionID !== r.sourceSessionID ? r.sessionID : null)
|
|
51
|
+
add(tag, child, 'forked', at)
|
|
52
|
+
} else add(tag, r.sessionID ?? r.sourceSessionID, 'continued', at)
|
|
53
|
+
// A sourceSessionID is where a fork or new session came FROM, never membership (precision fix 2).
|
|
54
|
+
}
|
|
55
|
+
for (const h of hits) {
|
|
56
|
+
if (h.kind === 'prompt') add(h.tag, h.sid, 'prompt', h.at)
|
|
57
|
+
else if (h.kind === 'status') add(h.tag, h.sid, 'status', h.at)
|
|
58
|
+
}
|
|
59
|
+
for (const row of snapshot?.live?.rows ?? []) {
|
|
60
|
+
for (const tag of tagsIn(row.summary)) add(tag, row.id, 'listed', row.updatedAt ? Date.parse(row.updatedAt) || null : null)
|
|
61
|
+
}
|
|
62
|
+
return out
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const newest = (list: Ev[], kinds?: ReadonlySet<Evidence>) => list.filter(e => !kinds || kinds.has(e.kind)).reduce<number | null>((m, e) => (e.at !== null && (m === null || e.at > m) ? e.at : m), null)
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The card's sessions. `meetingWorkIDs`: the card's confirmed source meetings as meeting cards (`meeting:<recordId>`);
|
|
69
|
+
* a session that is a member of one of them is suggested here with the reason (rule 8).
|
|
70
|
+
*/
|
|
71
|
+
export function discoverCardSessions(workID: string, snapshot: ControlSnapshot | null, hits: TagHit[], meetingWorkIDs: string[] = []): CardSession[] {
|
|
72
|
+
const byTag = strongByTag(snapshot, hits)
|
|
73
|
+
const tag = tagForWorkID(workID)
|
|
74
|
+
const mine = new Map<string, Ev[]>(byTag.get(tag) ?? [])
|
|
75
|
+
for (const m of meetingWorkIDs) {
|
|
76
|
+
for (const [sid, list] of byTag.get(tagForWorkID(m)) ?? []) {
|
|
77
|
+
if (!list.some(e => MEMBER.has(e.kind))) continue
|
|
78
|
+
const at = newest(list, MEMBER)
|
|
79
|
+
mine.set(sid, [...(mine.get(sid) ?? []), { kind: 'meeting', at }])
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const out: CardSession[] = []
|
|
83
|
+
for (const [sid, list] of mine) {
|
|
84
|
+
const kinds = EVIDENCE_RANK.filter(k => list.some(e => e.kind === k))
|
|
85
|
+
const member = kinds.some(k => MEMBER.has(k))
|
|
86
|
+
if (!member && !kinds.some(k => CONFIRMS.has(k) || k === 'meeting')) continue
|
|
87
|
+
// Newest strong evidence decides "working on" (precision fix 5): another card with newer member evidence wins.
|
|
88
|
+
const here = newest(list, MEMBER) ?? newest(list)
|
|
89
|
+
let elsewhere: number | null = null
|
|
90
|
+
for (const [otherTag, sessions] of byTag) {
|
|
91
|
+
if (otherTag === tag || meetingWorkIDs.some(m => tagForWorkID(m) === otherTag)) continue
|
|
92
|
+
const t = newest(sessions.get(sid) ?? [], MEMBER)
|
|
93
|
+
if (t !== null && (elsewhere === null || t > elsewhere)) elsewhere = t
|
|
94
|
+
}
|
|
95
|
+
out.push({ id: sid, discoveredBy: kinds, member, linked: kinds.includes('link'), lastTouchedCard: newest(list),
|
|
96
|
+
onAnotherCard: elsewhere !== null && here !== null && elsewhere > here })
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0: the orchestrator input for one Work card. Joins discovery, the live session list Control just read, the
|
|
3
|
+
* pick-up notes and the usage snapshot into the rows the planner sees, ranked and bounded, with what was left out and
|
|
4
|
+
* why. It sends nothing and changes nothing.
|
|
5
|
+
*/
|
|
6
|
+
import type { ControlSnapshot, SnapshotLiveRow } from './snapshot.js'
|
|
7
|
+
import { discoverCardSessions, type CardSession, type Evidence } from './discovery.js'
|
|
8
|
+
import { pickupNote, PICKUP_MAX_CHARS } from './pickup.js'
|
|
9
|
+
import type { UsageProvider, UsageView } from './usage.js'
|
|
10
|
+
import type { LogProvider } from './session-log.js'
|
|
11
|
+
|
|
12
|
+
export const ORCHESTRATOR_MAX_ROWS = 12
|
|
13
|
+
export const ORCHESTRATOR_MAX_LEFT_OUT = 12
|
|
14
|
+
/** The live list Control sends is "just read" for this long; older, rows read as not in the session list. */
|
|
15
|
+
export const LIVE_FRESH_MS = 3 * 60_000
|
|
16
|
+
|
|
17
|
+
export const UNAVAILABLE_REASONS = ['not_in_session_list', 'app_owned', 'cursor', 'plan_limit', 'on_another_card'] as const
|
|
18
|
+
export type UnavailableReason = typeof UNAVAILABLE_REASONS[number]
|
|
19
|
+
|
|
20
|
+
export interface OrchestratorRow {
|
|
21
|
+
id: string; title: string; summary: string; status: string
|
|
22
|
+
discoveredBy: Evidence[]; linked: boolean; member: boolean
|
|
23
|
+
lastTouchedCard: string | null; lastActivity: string | null
|
|
24
|
+
live: boolean; busy: boolean; pickup: string
|
|
25
|
+
available: boolean; reason?: UnavailableReason
|
|
26
|
+
/** A fork can start from it: Claude or Codex, its log is on this Mac, and that provider has plan usage left. */
|
|
27
|
+
forkable: boolean
|
|
28
|
+
}
|
|
29
|
+
export interface LeftOut { id: string; title: string; reason: string }
|
|
30
|
+
export interface OrchestratorInput {
|
|
31
|
+
protocolVersion: 1; workID: string; now: string
|
|
32
|
+
rows: OrchestratorRow[]; leftOut: LeftOut[]; usage: UsageView[]
|
|
33
|
+
snapshotAgeSec: number | null; liveListAgeSec: number | null; logScanComplete: boolean
|
|
34
|
+
discovered: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface InputDeps {
|
|
38
|
+
snapshot: ControlSnapshot | null
|
|
39
|
+
hits: import('./tag-index.js').TagHit[]
|
|
40
|
+
usage: UsageView[]
|
|
41
|
+
/** Log path for a session, or null when this Mac has none. */
|
|
42
|
+
pathOf: (sid: string) => Promise<string | null>
|
|
43
|
+
/** Last-modified time of a log, for sessions outside the live list. */
|
|
44
|
+
mtimeOf?: (path: string) => Promise<number | null>
|
|
45
|
+
skipped?: Set<string>
|
|
46
|
+
logScanComplete?: boolean
|
|
47
|
+
now?: number
|
|
48
|
+
home?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const iso = (ms: number | null | undefined) => (ms ? new Date(ms).toISOString() : null)
|
|
52
|
+
|
|
53
|
+
export async function buildOrchestratorInput(workID: string, meetingWorkIDs: string[], deps: InputDeps): Promise<OrchestratorInput> {
|
|
54
|
+
const now = deps.now ?? Date.now()
|
|
55
|
+
const snapshot = deps.snapshot
|
|
56
|
+
const liveFresh = !!snapshot?.live && now - snapshot.live.checkedAt * 1000 <= LIVE_FRESH_MS
|
|
57
|
+
const live = new Map<string, SnapshotLiveRow>((liveFresh ? snapshot!.live!.rows : []).map(r => [r.id, r]))
|
|
58
|
+
const usageBy = new Map<UsageProvider, UsageView>(deps.usage.map(u => [u.provider, u]))
|
|
59
|
+
const found = discoverCardSessions(workID, snapshot, deps.hits, meetingWorkIDs)
|
|
60
|
+
const leftOut: LeftOut[] = []
|
|
61
|
+
const candidates: Array<{ s: CardSession; row: SnapshotLiveRow | undefined }> = []
|
|
62
|
+
for (const s of found) {
|
|
63
|
+
if (deps.skipped?.has(s.id)) { leftOut.push({ id: s.id, title: '', reason: 'codex child thread (guardian or subagent)' }); continue }
|
|
64
|
+
candidates.push({ s, row: live.get(s.id) })
|
|
65
|
+
}
|
|
66
|
+
// Members before suggestions; in the live list before not; the session that touched this card most recently first.
|
|
67
|
+
candidates.sort((a, b) => Number(b.s.member) - Number(a.s.member) || Number(!!b.row) - Number(!!a.row)
|
|
68
|
+
|| Number(!b.s.onAnotherCard) - Number(!a.s.onAnotherCard) || (b.s.lastTouchedCard ?? 0) - (a.s.lastTouchedCard ?? 0) || a.s.id.localeCompare(b.s.id))
|
|
69
|
+
const kept = candidates.slice(0, ORCHESTRATOR_MAX_ROWS)
|
|
70
|
+
for (const c of candidates.slice(ORCHESTRATOR_MAX_ROWS)) leftOut.push({ id: c.s.id, title: c.row?.title ?? '', reason: 'more than 12 sessions on this card' })
|
|
71
|
+
const rows: OrchestratorRow[] = []
|
|
72
|
+
for (const { s, row } of kept) {
|
|
73
|
+
const provider = s.id.split(':')[0] as LogProvider
|
|
74
|
+
const path = await deps.pathOf(s.id).catch(() => null)
|
|
75
|
+
let pickup = 'No log for this session on this Mac.'
|
|
76
|
+
let lastActivity: string | null = row?.updatedAt ?? null
|
|
77
|
+
if (path) {
|
|
78
|
+
try {
|
|
79
|
+
const note = await pickupNote(path, provider, s.id.split(':')[1]!, row ? { busy: row.busy, waitingOnUser: row.waitingOnUser } : undefined, deps.home)
|
|
80
|
+
pickup = note.note.slice(0, PICKUP_MAX_CHARS)
|
|
81
|
+
lastActivity = lastActivity ?? note.lastActivity
|
|
82
|
+
} catch { pickup = 'The log could not be read.' }
|
|
83
|
+
if (!lastActivity && deps.mtimeOf) lastActivity = iso(await deps.mtimeOf(path).catch(() => null))
|
|
84
|
+
}
|
|
85
|
+
const usage = provider === 'cursor' ? undefined : usageBy.get(provider)
|
|
86
|
+
const exhausted = usage?.state === 'exhausted'
|
|
87
|
+
let reason: UnavailableReason | undefined
|
|
88
|
+
if (provider === 'cursor') reason = 'cursor'
|
|
89
|
+
else if (s.onAnotherCard) reason = 'on_another_card'
|
|
90
|
+
else if (exhausted) reason = 'plan_limit'
|
|
91
|
+
else if (!row) reason = 'not_in_session_list'
|
|
92
|
+
else if (row.appOwned) reason = 'app_owned'
|
|
93
|
+
rows.push({
|
|
94
|
+
id: s.id, title: (row?.title || '').slice(0, 300) || s.id.slice(0, 15), summary: (row?.summary ?? '').slice(0, 2000),
|
|
95
|
+
status: (row ? row.status || 'idle' : 'not in the session list').slice(0, 100),
|
|
96
|
+
discoveredBy: s.discoveredBy, linked: s.linked, member: s.member,
|
|
97
|
+
lastTouchedCard: iso(s.lastTouchedCard), lastActivity,
|
|
98
|
+
live: !!row, busy: !!row?.busy, pickup,
|
|
99
|
+
available: !reason, ...(reason ? { reason } : {}),
|
|
100
|
+
forkable: (provider === 'claude' || provider === 'codex') && !!path && !exhausted,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
protocolVersion: 1, workID, now: new Date(now).toISOString(), rows, leftOut: leftOut.slice(0, ORCHESTRATOR_MAX_LEFT_OUT), usage: deps.usage,
|
|
105
|
+
snapshotAgeSec: snapshot ? Math.round(now / 1000 - snapshot.receivedAt) : null,
|
|
106
|
+
liveListAgeSec: snapshot?.live ? Math.round(now / 1000 - snapshot.live.checkedAt) : null,
|
|
107
|
+
logScanComplete: deps.logScanComplete ?? true, discovered: found.length,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0 (M2 planner input, canary C2): a bounded, deterministic "where it stopped, what is next" note from the tail of
|
|
3
|
+
* a session's log. No model call. The note goes only into the planner input; it is redacted (secrets and customer
|
|
4
|
+
* identifiers) before it leaves this module, and never longer than PICKUP_MAX_CHARS.
|
|
5
|
+
*/
|
|
6
|
+
import { readTail, turnsFrom, type LogProvider, type Turn } from './session-log.js'
|
|
7
|
+
import { redactNote } from './redact.js'
|
|
8
|
+
import { statusLines } from './tags.js'
|
|
9
|
+
|
|
10
|
+
export const PICKUP_MAX_CHARS = 1500
|
|
11
|
+
|
|
12
|
+
/** User turns the harness writes, not the person: task notifications, compaction summaries, interrupts. */
|
|
13
|
+
const HARNESS = ['Perform any necessary follow-up actions', 'Briefly inform the user about the task result', '<task-notification>',
|
|
14
|
+
'This session is being continued from a previous conversation', '[Request interrupted', 'The following is the Codex agent history']
|
|
15
|
+
|
|
16
|
+
export interface LiveOverlay { busy?: boolean; waitingOnUser?: boolean }
|
|
17
|
+
|
|
18
|
+
const clip = (s: string, n: number) => {
|
|
19
|
+
const t = (s || '').replace(/\s+/g, ' ').trim()
|
|
20
|
+
return t.length <= n ? t : t.slice(0, n - 1).trimEnd() + '…'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function pendingTodos(turns: Turn[]): string[] {
|
|
24
|
+
for (let i = turns.length - 1; i >= 0; i--) {
|
|
25
|
+
const t = turns[i]!
|
|
26
|
+
if (t.role !== 'todo') continue
|
|
27
|
+
let items: unknown
|
|
28
|
+
try { items = JSON.parse(t.text) } catch { items = undefined }
|
|
29
|
+
if (typeof items === 'string') { try { items = JSON.parse(items) } catch { items = undefined } }
|
|
30
|
+
if (items && typeof items === 'object' && !Array.isArray(items)) items = (items as any).plan ?? (items as any).todos
|
|
31
|
+
if (!Array.isArray(items)) return []
|
|
32
|
+
return items.filter(it => it && typeof it === 'object' && !['completed', 'done'].includes(String((it as any).status ?? '').toLowerCase()))
|
|
33
|
+
.map(it => `${String((it as any).status || 'open')}: ${String((it as any).content ?? (it as any).step ?? (it as any).activeForm ?? '')}`)
|
|
34
|
+
}
|
|
35
|
+
return []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function questionsIn(text: string): string[] {
|
|
39
|
+
const out: string[] = []
|
|
40
|
+
for (const piece of text.split(/(?<=[.?!])\s+|\n/)) {
|
|
41
|
+
const l = piece.trim().replace(/^[-*#> ]+/, '').replace(/[-*#> ]+$/, '')
|
|
42
|
+
if (l.endsWith('?') && l.length > 12 && l.length < 300 && !/^(https?:|what i |why )/i.test(l)) out.push(l)
|
|
43
|
+
}
|
|
44
|
+
return out.slice(-4)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PickupNote { note: string; lastActivity: string | null; state: 'running' | 'mid_turn' | 'after_tools' | 'finished' | 'empty' }
|
|
48
|
+
|
|
49
|
+
/** The note from turns already read. `mtimeMs` stands in for Cursor's missing timestamps. */
|
|
50
|
+
export function pickupFromTurns(turns: Turn[], options: { live?: LiveOverlay; mtimeMs?: number; home?: string } = {}): PickupNote {
|
|
51
|
+
const r = (s: string) => redactNote(s, options.home)
|
|
52
|
+
const human = turns.filter(t => t.role === 'user' && t.text.trim() && !/^\s*[<{]/.test(t.text) && !HARNESS.some(h => t.text.trimStart().startsWith(h)))
|
|
53
|
+
const replies = turns.filter(t => t.role === 'assistant' && t.text.trim())
|
|
54
|
+
if (!human.length && !replies.length) return { note: 'No readable turns in the log tail.', lastActivity: null, state: 'empty' }
|
|
55
|
+
const lastUser = human[human.length - 1]
|
|
56
|
+
const lastReply = replies[replies.length - 1]
|
|
57
|
+
const iUser = turns.lastIndexOf(lastUser!), iReply = turns.lastIndexOf(lastReply!)
|
|
58
|
+
const toolsAfter = iReply >= 0 ? turns.slice(iReply + 1).filter(t => t.role === 'tool').length : 0
|
|
59
|
+
let state: PickupNote['state'] = iUser > iReply ? 'mid_turn' : toolsAfter ? 'after_tools' : 'finished'
|
|
60
|
+
let stateLine = state === 'mid_turn' ? 'Stopped mid-turn: the last ask has no reply yet.'
|
|
61
|
+
: state === 'after_tools' ? `Stopped after ${toolsAfter} tool call(s) with no closing reply.` : 'Stopped after a reply (turn finished).'
|
|
62
|
+
if (options.live?.busy) { state = 'running'; stateLine = 'Still running: a turn is in flight now, so this is a moving snapshot.' }
|
|
63
|
+
else if (options.live?.waitingOnUser) stateLine += ' The session list says it is waiting on the user.'
|
|
64
|
+
const stamp = lastReply?.at ?? lastUser?.at ?? null
|
|
65
|
+
const lastActivity = stamp ?? (options.mtimeMs ? new Date(options.mtimeMs).toISOString() : null)
|
|
66
|
+
const when = stamp ? `${stamp.slice(0, 16).replace('T', ' ')} UTC` : options.mtimeMs ? `${new Date(options.mtimeMs).toISOString().slice(0, 16).replace('T', ' ')} UTC (file time)` : 'unknown'
|
|
67
|
+
const parts = [`State: ${stateLine} Last activity ${when}.`]
|
|
68
|
+
parts.push('Last ask: ' + clip(r(lastUser?.text ?? ''), 300))
|
|
69
|
+
parts.push('Last result: ' + clip(r(lastReply?.text ?? ''), 520))
|
|
70
|
+
const status = lastReply ? statusLines(lastReply.text)[0] : undefined
|
|
71
|
+
if (status) parts.push('Status line: ' + clip(r(`${status.status}: ${status.evidence}`), 160))
|
|
72
|
+
const todos = pendingTodos(turns)
|
|
73
|
+
if (todos.length) parts.push('Open todos: ' + clip(r(todos.join('; ')), 220))
|
|
74
|
+
const qs = lastReply ? questionsIn(lastReply.text) : []
|
|
75
|
+
parts.push('Open questions: ' + (qs.length ? clip(r(qs.join(' | ')), 260) : 'none found in the last reply.'))
|
|
76
|
+
let note = parts.join('\n')
|
|
77
|
+
if (note.length > PICKUP_MAX_CHARS) note = note.slice(0, PICKUP_MAX_CHARS - 1) + '…'
|
|
78
|
+
return { note, lastActivity, state }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function pickupNote(path: string, provider: LogProvider, sessionID: string, live?: LiveOverlay, home?: string): Promise<PickupNote> {
|
|
82
|
+
const tail = await readTail(path)
|
|
83
|
+
const turns = turnsFrom(tail.text, provider, provider === 'claude' ? sessionID : undefined)
|
|
84
|
+
return pickupFromTurns(turns, { live, mtimeMs: tail.mtimeMs, home })
|
|
85
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0: every Work chat plan, from any client, leaves one JSONL row and one server log line: the sessions sent with
|
|
3
|
+
* their availability, what was left out and why, the usage snapshot's age per provider, the assignments (mode and
|
|
4
|
+
* target, never prompt text), the planner, the outcome and the time taken. Bounded: past 2 MB the file keeps its newest
|
|
5
|
+
* half. Ids and enums only; no question, prompt, summary or pick-up text is written.
|
|
6
|
+
*/
|
|
7
|
+
import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs'
|
|
8
|
+
import { dirname } from 'node:path'
|
|
9
|
+
import { atomicWriteFileSync } from '../atomic-fs.js'
|
|
10
|
+
|
|
11
|
+
export const PLAN_LOG_MAX_BYTES = 2 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
export interface PlanLogRow {
|
|
14
|
+
at: string; workID: string; mode: string; planner: string; effort: string; orchestrator: boolean
|
|
15
|
+
sessions: Array<{ id: string; automatic: boolean; available?: boolean; reason?: string; discoveredBy?: string[] }>
|
|
16
|
+
leftOut: Array<{ id: string; reason: string }>
|
|
17
|
+
usageAgeSec: Record<string, number | null>
|
|
18
|
+
usageState: Record<string, string>
|
|
19
|
+
assignments: Array<{ mode: string; target: string }>
|
|
20
|
+
outcome: 'planned' | 'invalid_answer' | 'unavailable' | 'aborted'
|
|
21
|
+
ms: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function appendPlanLog(path: string, row: PlanLogRow): void {
|
|
25
|
+
try {
|
|
26
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
27
|
+
let size = 0
|
|
28
|
+
try { size = statSync(path).size } catch { /* new file */ }
|
|
29
|
+
if (size > PLAN_LOG_MAX_BYTES) {
|
|
30
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean)
|
|
31
|
+
atomicWriteFileSync(path, lines.slice(Math.floor(lines.length / 2)).join('\n') + '\n')
|
|
32
|
+
}
|
|
33
|
+
appendFileSync(path, JSON.stringify(row) + '\n', { mode: 0o600 })
|
|
34
|
+
} catch (e) { console.warn(`[work-orchestrator] plan log not written: ${(e as Error).message}`) }
|
|
35
|
+
const sent = row.sessions.map(s => `${s.id.slice(0, 15)}${s.automatic ? '' : '(no-continue)'}${s.reason ? `[${s.reason}]` : ''}`).join(',') || 'none'
|
|
36
|
+
const usage = Object.entries(row.usageState).map(([p, s]) => `${p}=${s}/${row.usageAgeSec[p] ?? '-'}s`).join(' ') || 'none'
|
|
37
|
+
console.log(`[work-orchestrator] plan work=${row.workID} outcome=${row.outcome} planner=${row.planner}/${row.effort} orchestrator=${row.orchestrator} sessions=${sent} leftOut=${row.leftOut.length} usage=${usage} assignments=${row.assignments.map(a => `${a.mode}:${a.target.slice(0, 15)}`).join(',') || 'none'} ms=${row.ms}`)
|
|
38
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0: what a pick-up note may carry. Session logs hold secrets and customer identifiers; a note goes to the planner
|
|
3
|
+
* (and from there into a prompt), so both are removed here, before the note leaves the orchestrator.
|
|
4
|
+
*
|
|
5
|
+
* Secrets: the server's shared `redactSecrets` patterns, plus `name=value` secrets and credential file paths (canary C2).
|
|
6
|
+
* Customer identifiers (canary C2, N1 named a merchant's proposal slug with its fees): email addresses, phone numbers,
|
|
7
|
+
* the path and query of any URL (the host stays, so "bottlepos.com" still says where), money amounts, and long numeric
|
|
8
|
+
* ids (order, account, ticket and HubSpot ids). The replacements say what was there, never what it was.
|
|
9
|
+
*/
|
|
10
|
+
import { homedir } from 'node:os'
|
|
11
|
+
import { redactSecrets } from '../lens-gist-engines.js'
|
|
12
|
+
|
|
13
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
14
|
+
|
|
15
|
+
function homeRes(home: string): Array<[RegExp, string]> {
|
|
16
|
+
const h = `(?:~|${escape(home)})`
|
|
17
|
+
return [
|
|
18
|
+
[new RegExp(`${h}/\\.(?:codex/auth\\.json|ssh/[\\w.\\-]+|aws/credentials|npmrc|netrc|config/gh/hosts\\.yml)`, 'g'), '[credential path]'],
|
|
19
|
+
[new RegExp(`${h}/\\.cos-glasses/[\\w.\\-/]*(?:token|key|secret)[\\w.\\-]*`, 'gi'), '[credential path]'],
|
|
20
|
+
// A path to an env file, or a bare `.env` token. `process.env` is code, not a file, and stays.
|
|
21
|
+
[new RegExp(`(?<![\\w])${h}?(?:/[\\w\\-.]+)*/\\.env(?:\\.[\\w-]+)?\\b|(?<![\\w.])\\.env(?:\\.[\\w-]+)?\\b`, 'g'), '[env file]'],
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SECRET_ASSIGN = /\b(token|api[_-]?key|secret|password|passwd|x-cos-token|client[_-]?secret)\b(["']?\s*[:=]\s*["']?)[A-Za-z0-9_\-./+=]{12,}/gi
|
|
26
|
+
const CUSTOMER: Array<[RegExp, string]> = [
|
|
27
|
+
[/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, '[email]'],
|
|
28
|
+
// A URL keeps its scheme and host; its path, query and fragment can name a customer (slugs, ids, proposals).
|
|
29
|
+
[/\b(https?:\/\/[A-Za-z0-9.-]+(?::\d+)?)(?:[/?#][^\s)\]"'<>]*)/g, '$1/[path]'],
|
|
30
|
+
[/(?<![\w.])(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?!\d)/g, '[phone]'],
|
|
31
|
+
[/(?:[$£€]\s?\d[\d,]*(?:\.\d+)?(?:\s?(?:k|m|million|thousand))?|\b\d[\d,]*(?:\.\d+)?\s?(?:USD|dollars)\b)/gi, '[amount]'],
|
|
32
|
+
[/\b\d{6,}\b/g, '[id]'],
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
export function redactNote(text: string, home = homedir()): string {
|
|
36
|
+
let out = redactSecrets(text)
|
|
37
|
+
out = out.replace(SECRET_ASSIGN, '$1=[secret]')
|
|
38
|
+
for (const [re, to] of homeRes(home)) out = out.replace(re, to)
|
|
39
|
+
for (const [re, to] of CUSTOMER) out = out.replace(re, to)
|
|
40
|
+
return home.length > 1 ? out.split(home).join('~') : out
|
|
41
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0: the orchestrator's process-wide state, created on first use (importing this module starts nothing).
|
|
3
|
+
* Files live under `data/work-orchestrator/`.
|
|
4
|
+
*/
|
|
5
|
+
import { stat } from 'node:fs/promises'
|
|
6
|
+
import { dataPath } from '../data-dir.js'
|
|
7
|
+
import { agentSessionRoots, findAgentSessionFile, type AgentProvider } from '../agent-session-store.js'
|
|
8
|
+
import { setUsageEventSink } from './usage-events.js'
|
|
9
|
+
import { SnapshotStore, type ControlSnapshot } from './snapshot.js'
|
|
10
|
+
import { TagIndex } from './tag-index.js'
|
|
11
|
+
import { UsageMonitor, type UsageProvider } from './usage.js'
|
|
12
|
+
import { buildOrchestratorInput, type OrchestratorInput } from './input.js'
|
|
13
|
+
|
|
14
|
+
let snapshots: SnapshotStore | null = null
|
|
15
|
+
let index: TagIndex | null = null
|
|
16
|
+
let usage: UsageMonitor | null = null
|
|
17
|
+
|
|
18
|
+
export function orchestratorSnapshots(): SnapshotStore { return snapshots ??= new SnapshotStore(dataPath('work-orchestrator', 'control-snapshot.json')) }
|
|
19
|
+
export function orchestratorTagIndex(): TagIndex { return index ??= new TagIndex({ roots: agentSessionRoots(), path: dataPath('work-orchestrator', 'tag-index.json') }) }
|
|
20
|
+
export function orchestratorUsage(): UsageMonitor {
|
|
21
|
+
if (!usage) {
|
|
22
|
+
// Inside a test the real probes are never reachable: any test that reads usage through this monitor gets an
|
|
23
|
+
// error reading, never a spawned claude or codex CLI (the boot guard below covers the poll; this covers on demand).
|
|
24
|
+
const inTest = !!(process.env.VITEST || process.env.NODE_ENV === 'test')
|
|
25
|
+
const noProbe = async (): Promise<never> => { throw new Error('not probed inside a test') }
|
|
26
|
+
const monitor = usage = new UsageMonitor({ path: dataPath('work-orchestrator', 'usage.json'), ...(inTest ? { probes: { claude: noProbe, codex: noProbe } } : {}) })
|
|
27
|
+
// Jobs report limits and rate-limit frames through the leaf module; this monitor is where they land.
|
|
28
|
+
setUsageEventSink({ limit: (p, message, source) => monitor.noteLimit(p as UsageProvider, message, source), claudeFrame: frame => monitor.noteClaudeRateLimitEvent(frame) })
|
|
29
|
+
}
|
|
30
|
+
return usage
|
|
31
|
+
}
|
|
32
|
+
export const PLAN_LOG_PATH = () => dataPath('work-orchestrator', 'plans.jsonl')
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Called once by the server at boot: registers the usage-event sink and schedules a first log scan. Usage is read on
|
|
36
|
+
* demand (a plan input or GET /usage); the 10-minute background poll starts only with `COS_WORK_USAGE_POLL=1`.
|
|
37
|
+
*/
|
|
38
|
+
export function startWorkOrchestrator(env: NodeJS.ProcessEnv = process.env): void {
|
|
39
|
+
// A test that boots the server must never spawn the real claude or codex CLI, nor scan this Mac's session logs.
|
|
40
|
+
orchestratorUsage() // registers the usage-event sink, so job limits and rate-limit frames are kept from boot
|
|
41
|
+
if (env.VITEST || env.NODE_ENV === 'test') return
|
|
42
|
+
orchestratorUsage().start(env) // opt-in only: a no-op unless COS_WORK_USAGE_POLL=1
|
|
43
|
+
setTimeout(() => { void orchestratorTagIndex().refresh().catch(() => {}) }, 45_000).unref?.()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** How long a plan waits for the log scan before it answers with what the index already holds. */
|
|
47
|
+
export const INPUT_SCAN_WAIT_MS = 20_000
|
|
48
|
+
|
|
49
|
+
/** What one plan input reads. The server passes the process-wide stores; a test passes its own. */
|
|
50
|
+
export interface OrchestratorParts { tags: TagIndex; usage: UsageMonitor; snapshot: () => ControlSnapshot | null }
|
|
51
|
+
|
|
52
|
+
export async function orchestratorInputFor(workID: string, meetingWorkIDs: string[], parts?: OrchestratorParts): Promise<OrchestratorInput> {
|
|
53
|
+
const tags = parts?.tags ?? orchestratorTagIndex()
|
|
54
|
+
const monitor = parts?.usage ?? orchestratorUsage()
|
|
55
|
+
const scan = tags.refresh().catch(() => null)
|
|
56
|
+
const scanned = await Promise.race([scan, new Promise<null>(r => setTimeout(() => r(null), INPUT_SCAN_WAIT_MS).unref?.())])
|
|
57
|
+
// On demand: the plan this input feeds may fork or start a session, so a reading older than 2 minutes is re-read.
|
|
58
|
+
const usageViews = await monitor.forPlan()
|
|
59
|
+
const roots = agentSessionRoots()
|
|
60
|
+
return buildOrchestratorInput(workID, meetingWorkIDs, {
|
|
61
|
+
snapshot: parts ? parts.snapshot() : orchestratorSnapshots().get(),
|
|
62
|
+
hits: tags.hits(),
|
|
63
|
+
usage: usageViews,
|
|
64
|
+
skipped: tags.skippedSessions(),
|
|
65
|
+
logScanComplete: !!scanned?.complete,
|
|
66
|
+
pathOf: async sid => {
|
|
67
|
+
const known = tags.pathOf(sid)
|
|
68
|
+
if (known) return known
|
|
69
|
+
const [provider, id] = sid.split(':') as [AgentProvider, string]
|
|
70
|
+
return findAgentSessionFile(provider, id, roots)
|
|
71
|
+
},
|
|
72
|
+
mtimeOf: async path => (await stat(path)).mtimeMs,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.0: read the END of a session log without reading the whole file, and turn its rows into human-visible turns.
|
|
3
|
+
* Codex rollouts reach 1.4 GB (canary C2), so the reader seeks from the end: at most `TAIL_BYTES`, starting at the first
|
|
4
|
+
* whole line. Read-only; never writes beside the log.
|
|
5
|
+
*
|
|
6
|
+
* Row shapes (measured in canary C2 on 2026-10-20):
|
|
7
|
+
* Claude {type:'user'|'assistant', sessionId, timestamp, isSidechain, isMeta, message:{role, content}}
|
|
8
|
+
* Cursor {role, message:{content:[{type:'text', text:'<user_query>…</user_query>'}]}} (no timestamps: use mtime)
|
|
9
|
+
* Codex {timestamp, type:'response_item', payload:{type:'message', role, content:[{text}]}} and function calls
|
|
10
|
+
*/
|
|
11
|
+
import { open, stat } from 'node:fs/promises'
|
|
12
|
+
|
|
13
|
+
export const TAIL_BYTES = 6_000_000
|
|
14
|
+
export type LogProvider = 'claude' | 'codex' | 'cursor'
|
|
15
|
+
export type TurnRole = 'user' | 'assistant' | 'tool' | 'todo' | 'ask'
|
|
16
|
+
export interface Turn { role: TurnRole; text: string; at: string | null }
|
|
17
|
+
|
|
18
|
+
/** Harness text that is not the person's ask nor the agent's answer. */
|
|
19
|
+
const NOISE = ['<environment_context>', '<user_instructions>', '# AGENTS.md', '<permissions instructions>', '<system-reminder>',
|
|
20
|
+
'<command-name>', '<local-command', 'Caveat: The messages below', '<external_codex_apps_open_page>', '<turn_aborted>']
|
|
21
|
+
const noisy = (text: string) => { const head = text.slice(0, 300); return NOISE.some(n => head.includes(n)) }
|
|
22
|
+
|
|
23
|
+
export async function readTail(path: string, maxBytes = TAIL_BYTES): Promise<{ text: string; size: number; mtimeMs: number; truncated: boolean }> {
|
|
24
|
+
const info = await stat(path)
|
|
25
|
+
const start = Math.max(0, info.size - maxBytes)
|
|
26
|
+
const handle = await open(path, 'r')
|
|
27
|
+
try {
|
|
28
|
+
const length = info.size - start
|
|
29
|
+
const buffer = Buffer.alloc(length)
|
|
30
|
+
let read = 0
|
|
31
|
+
while (read < length) {
|
|
32
|
+
const { bytesRead } = await handle.read(buffer, read, length - read, start + read)
|
|
33
|
+
if (bytesRead === 0) break
|
|
34
|
+
read += bytesRead
|
|
35
|
+
}
|
|
36
|
+
let text = buffer.subarray(0, read).toString('utf8')
|
|
37
|
+
// Seeking lands mid-line: drop the partial first line so every row parses whole.
|
|
38
|
+
if (start > 0) { const nl = text.indexOf('\n'); text = nl < 0 ? '' : text.slice(nl + 1) }
|
|
39
|
+
return { text, size: info.size, mtimeMs: info.mtimeMs, truncated: start > 0 }
|
|
40
|
+
} finally { await handle.close() }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function record(line: string): Record<string, any> | null {
|
|
44
|
+
try { const v = JSON.parse(line); return v && typeof v === 'object' && !Array.isArray(v) ? v : null } catch { return null }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Turns from JSONL text. `onlySession`: a Claude file can replay rows of the session it was resumed from; only rows whose
|
|
49
|
+
* own `sessionId` is this one count (canary C3 precision fix 4). Rows without a sessionId are kept.
|
|
50
|
+
*/
|
|
51
|
+
export function turnsFrom(text: string, provider: LogProvider, onlySession?: string): Turn[] {
|
|
52
|
+
const out: Turn[] = []
|
|
53
|
+
const want = onlySession?.toLowerCase()
|
|
54
|
+
for (const line of text.split('\n')) {
|
|
55
|
+
if (!line.trim()) continue
|
|
56
|
+
const o = record(line)
|
|
57
|
+
if (!o) continue
|
|
58
|
+
const at = typeof o.timestamp === 'string' ? o.timestamp : null
|
|
59
|
+
if (provider === 'claude' || provider === 'cursor') {
|
|
60
|
+
if (provider === 'claude') {
|
|
61
|
+
if (o.type !== 'user' && o.type !== 'assistant') continue
|
|
62
|
+
if (want && typeof o.sessionId === 'string' && o.sessionId.toLowerCase() !== want) continue
|
|
63
|
+
}
|
|
64
|
+
if (o.isSidechain || o.isMeta) continue
|
|
65
|
+
const m = o.message && typeof o.message === 'object' ? o.message : {}
|
|
66
|
+
const role = (m.role || o.role || o.type) as string
|
|
67
|
+
const content = m.content
|
|
68
|
+
if (typeof content === 'string') { if (!noisy(content)) out.push({ role: role === 'assistant' ? 'assistant' : 'user', text: content, at }); continue }
|
|
69
|
+
if (!Array.isArray(content)) continue
|
|
70
|
+
for (const b of content) {
|
|
71
|
+
if (!b || typeof b !== 'object') continue
|
|
72
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
73
|
+
let tx: string = b.text
|
|
74
|
+
if (provider === 'cursor') {
|
|
75
|
+
tx = tx.replace(/<timestamp>[\s\S]*?<\/timestamp>\s*/g, '')
|
|
76
|
+
const q = /<user_query>([\s\S]*?)<\/user_query>/.exec(tx)
|
|
77
|
+
if (q) tx = q[1]!
|
|
78
|
+
}
|
|
79
|
+
if (!noisy(tx)) out.push({ role: role === 'assistant' ? 'assistant' : 'user', text: tx, at })
|
|
80
|
+
} else if (b.type === 'tool_use') {
|
|
81
|
+
const input = b.input && typeof b.input === 'object' ? b.input : {}
|
|
82
|
+
if (b.name === 'TodoWrite' || Array.isArray(input.todos)) out.push({ role: 'todo', text: JSON.stringify(input.todos ?? []).slice(0, 3000), at })
|
|
83
|
+
else if (b.name === 'AskUserQuestion') out.push({ role: 'ask', text: JSON.stringify(input).slice(0, 1500), at })
|
|
84
|
+
else out.push({ role: 'tool', text: String(b.name || 'tool'), at })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
const p = o.payload && typeof o.payload === 'object' ? o.payload : {}
|
|
90
|
+
if (o.type === 'response_item' && p.type === 'message') {
|
|
91
|
+
const tx = (Array.isArray(p.content) ? p.content : []).map((b: any) => (b && typeof b.text === 'string' ? b.text : '')).join('\n')
|
|
92
|
+
if ((p.role === 'user' || p.role === 'assistant') && !noisy(tx)) out.push({ role: p.role, text: tx, at })
|
|
93
|
+
} else if (o.type === 'response_item' && (p.type === 'function_call' || p.type === 'custom_tool_call')) {
|
|
94
|
+
if (p.name === 'update_plan') out.push({ role: 'todo', text: String(p.arguments ?? '').slice(0, 3000), at })
|
|
95
|
+
else out.push({ role: 'tool', text: String(p.name || 'tool'), at })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The first line of a Codex rollout says whether it is a child thread (canary C3 precision fix 3). */
|
|
102
|
+
export interface CodexThreadKind { kind: 'thread' | 'guardian' | 'spawn' | 'other_child'; parent?: string }
|
|
103
|
+
export function codexThreadKind(firstLine: string): CodexThreadKind {
|
|
104
|
+
const meta = record(firstLine)?.payload
|
|
105
|
+
const source = meta && typeof meta === 'object' ? meta.source : undefined
|
|
106
|
+
if (!source || typeof source !== 'object' || !('subagent' in source)) return { kind: 'thread' }
|
|
107
|
+
const sub = (source as any).subagent
|
|
108
|
+
if (sub && typeof sub === 'object' && sub.thread_spawn && typeof sub.thread_spawn.parent_thread_id === 'string') {
|
|
109
|
+
return { kind: 'spawn', parent: sub.thread_spawn.parent_thread_id.toLowerCase() }
|
|
110
|
+
}
|
|
111
|
+
if (sub && typeof sub === 'object' && sub.other === 'guardian') return { kind: 'guardian' }
|
|
112
|
+
if (sub === 'guardian') return { kind: 'guardian' }
|
|
113
|
+
return { kind: 'other_child' }
|
|
114
|
+
}
|